mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
Compare commits
41 Commits
eval/skill
...
feat-histo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cefd885ec | ||
|
|
ec941c7949 | ||
|
|
19f0c0a3b6 | ||
|
|
d119b4b22d | ||
|
|
55b53bae0c | ||
|
|
e985518d22 | ||
|
|
d4cf6699c1 | ||
|
|
2cc1fa940b | ||
|
|
624f530d80 | ||
|
|
c0730b46bf | ||
|
|
751092c8ef | ||
|
|
deb0bd9dd6 | ||
|
|
c8de4e3692 | ||
|
|
422797305a | ||
|
|
a72331d007 | ||
|
|
9950a00da4 | ||
|
|
cf3c5f13eb | ||
|
|
b1e58d1340 | ||
|
|
0a17ddc45d | ||
|
|
773b93cb10 | ||
|
|
82a983888b | ||
|
|
9847b16d1a | ||
|
|
bed30c4ecb | ||
|
|
a7be567066 | ||
|
|
e96acad2c5 | ||
|
|
7ac8a7d30e | ||
|
|
31523b7f50 | ||
|
|
02a37029c2 | ||
|
|
556d7e3a77 | ||
|
|
f18a082a4f | ||
|
|
b8c5176483 | ||
|
|
82937a0a37 | ||
|
|
1cafb94a62 | ||
|
|
0b33daa136 | ||
|
|
5a61b97ac3 | ||
|
|
e01f2dfdd5 | ||
|
|
45f807459e | ||
|
|
8906e87fb1 | ||
|
|
d5a53d921d | ||
|
|
0ff7f0407e | ||
|
|
6e067f2180 |
@@ -66,6 +66,24 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, "page_size") {
|
||||
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_BotMode(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta {
|
||||
Description: desc,
|
||||
}
|
||||
}
|
||||
// Fallback: read from from_meta spec (legacy)
|
||||
meta := registry.LoadFromMeta(name)
|
||||
// Fallback: read from the typed service spec (legacy)
|
||||
dm := domainMeta{Name: name}
|
||||
if meta != nil {
|
||||
if t, ok := meta["title"].(string); ok {
|
||||
dm.Title = t
|
||||
}
|
||||
if d, ok := meta["description"].(string); ok {
|
||||
dm.Description = d
|
||||
}
|
||||
if svc, ok := registry.ServiceTyped(name); ok {
|
||||
dm.Title = svc.Title
|
||||
dm.Description = svc.Description
|
||||
}
|
||||
return dm
|
||||
}
|
||||
|
||||
52
cmd/command_catalog_path_test.go
Normal file
52
cmd/command_catalog_path_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
|
||||
// service command tree for any depth — flat dotted resources AND genuinely
|
||||
// nested resources — so it round-trips through apicatalog.Resolve instead of
|
||||
// assuming a fixed root->service->resource->method shape.
|
||||
func TestCommandCatalogPath(t *testing.T) {
|
||||
chain := func(names ...string) *cobra.Command {
|
||||
var parent, leaf *cobra.Command
|
||||
for _, n := range names {
|
||||
c := &cobra.Command{Use: n}
|
||||
if parent != nil {
|
||||
parent.AddCommand(c)
|
||||
}
|
||||
parent = c
|
||||
leaf = c
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
leaf *cobra.Command
|
||||
want []string
|
||||
}{
|
||||
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
|
||||
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
|
||||
{"service level", chain("lark-cli", "im"), []string{"im"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The root command (no parent) has no catalog path.
|
||||
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
|
||||
t.Errorf("root path = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -118,38 +119,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
}
|
||||
|
||||
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
|
||||
// service/resource/method command from the embedded from_meta registry.
|
||||
// service/resource/method command. It reconstructs the catalog path from the
|
||||
// command ancestry and resolves it through the same navigation Module the
|
||||
// command tree is built from (apicatalog), so it stays correct for nested
|
||||
// resources instead of hard-coding a root->service->resource->method depth.
|
||||
// Non-method commands (services, resources, shortcuts) resolve to a non-method
|
||||
// target and yield no scopes.
|
||||
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
|
||||
// Service-method scope lookup only applies to commands mounted as
|
||||
// root -> service -> resource -> method. Non-resource/method commands
|
||||
// intentionally return no scopes here so auth-hint enrichment does not
|
||||
// change runtime semantics for other command shapes.
|
||||
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
|
||||
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(cmd.Name(), "+") {
|
||||
path := commandCatalogPath(cmd)
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
target, err := registry.RuntimeCatalog().Resolve(path)
|
||||
if err != nil || target.Kind != apicatalog.TargetMethod {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
|
||||
}
|
||||
|
||||
service := cmd.Parent().Parent().Name()
|
||||
resource := cmd.Parent().Name()
|
||||
method := cmd.Name()
|
||||
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
return nil
|
||||
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
|
||||
// from a command's ancestry, excluding the root command. It is the inverse of
|
||||
// the service command tree's construction, so any depth (flat or nested)
|
||||
// round-trips through apicatalog.Resolve.
|
||||
func commandCatalogPath(cmd *cobra.Command) []string {
|
||||
var path []string
|
||||
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
|
||||
path = append([]string{c.Name()}, path...)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resMap, _ := resources[resource].(map[string]interface{})
|
||||
if resMap == nil {
|
||||
return nil
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methodMap, _ := methods[method].(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
||||
return path
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
|
||||
@@ -36,7 +36,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method> [--format pretty]
|
||||
lark-cli schema <service.resource.method>
|
||||
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
|
||||
@@ -5,17 +5,17 @@ package schema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -24,336 +24,10 @@ type SchemaOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
}
|
||||
|
||||
func printServices(w io.Writer) {
|
||||
services := registry.ListFromMetaProjects()
|
||||
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
|
||||
for _, s := range services {
|
||||
spec := registry.LoadFromMeta(s)
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
|
||||
}
|
||||
|
||||
func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) {
|
||||
name := registry.GetStrFromMap(spec, "name")
|
||||
version := registry.GetStrFromMap(spec, "version")
|
||||
title := registry.GetStrFromMap(spec, "title")
|
||||
if title == "" {
|
||||
title = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
|
||||
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
|
||||
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
|
||||
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
for _, resName := range sortedKeys(resources) {
|
||||
resMap, _ := resources[resName].(map[string]interface{})
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
if len(methods) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
|
||||
for _, methodName := range sortedKeys(methods) {
|
||||
m, _ := methods[methodName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
danger := ""
|
||||
if d, _ := m["danger"].(bool); d {
|
||||
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
|
||||
}
|
||||
|
||||
// hasFileFields returns true if any requestBody field has type "file".
|
||||
func hasFileFields(method map[string]interface{}) (bool, []string) {
|
||||
names := cmdutil.DetectFileFields(method)
|
||||
return len(names) > 0, names
|
||||
}
|
||||
|
||||
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
methodPath := registry.GetStrFromMap(method, "path")
|
||||
fullPath := servicePath + "/" + methodPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
isFileUpload, fileFieldNames := hasFileFields(method)
|
||||
|
||||
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
|
||||
|
||||
httpColor := output.Yellow
|
||||
if httpMethod == "GET" {
|
||||
httpColor = output.Green
|
||||
} else if httpMethod == "DELETE" {
|
||||
httpColor = output.Red
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
|
||||
if desc != "" {
|
||||
fmt.Fprintf(w, " %s\n", desc)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Parameters
|
||||
params, _ := method["parameters"].(map[string]interface{})
|
||||
if len(params) > 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
for _, paramName := range sortedParamKeys(params) {
|
||||
p, _ := params[paramName].(map[string]interface{})
|
||||
pType := registry.GetStrFromMap(p, "type")
|
||||
if pType == "" {
|
||||
pType = "string"
|
||||
}
|
||||
location := registry.GetStrFromMap(p, "location")
|
||||
required, _ := p["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
locColor := output.Dim
|
||||
if location == "path" {
|
||||
locColor = output.Yellow
|
||||
}
|
||||
// Options (enum values)
|
||||
optStr := formatOptions(p)
|
||||
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
|
||||
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
|
||||
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
|
||||
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
|
||||
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(p); rangeStr != "" {
|
||||
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --data for write methods
|
||||
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
|
||||
if len(params) == 0 {
|
||||
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
|
||||
}
|
||||
fileUploadTag := ""
|
||||
if isFileUpload {
|
||||
fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(w, " %s--data%s <json> %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag)
|
||||
requestBody, _ := method["requestBody"].(map[string]interface{})
|
||||
if len(requestBody) > 0 {
|
||||
printNestedFields(w, requestBody, " ", "")
|
||||
}
|
||||
|
||||
if isFileUpload {
|
||||
if len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, "\n %s--file%s <field=path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
|
||||
fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", "))
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Response
|
||||
responseBody, _ := method["responseBody"].(map[string]interface{})
|
||||
if len(responseBody) > 0 {
|
||||
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
|
||||
printNestedFields(w, responseBody, " ", "")
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// Identity
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
var identities []string
|
||||
for _, t := range tokens {
|
||||
if s, ok := t.(string); ok {
|
||||
switch s {
|
||||
case "user":
|
||||
identities = append(identities, "user")
|
||||
case "tenant":
|
||||
identities = append(identities, "bot")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(identities) > 0 {
|
||||
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes (all)
|
||||
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
|
||||
var scopeStrs []string
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok {
|
||||
scopeStrs = append(scopeStrs, str)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
|
||||
}
|
||||
|
||||
// CLI example
|
||||
if isFileUpload && len(fileFieldNames) == 1 {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else if isFileUpload {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file <field=path>\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
|
||||
}
|
||||
|
||||
// Docs
|
||||
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
|
||||
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
|
||||
for _, fieldName := range sortedFieldKeys(fields) {
|
||||
f, _ := fields[fieldName].(map[string]interface{})
|
||||
fullName := fieldName
|
||||
if prefix != "" {
|
||||
fullName = prefix + "." + fieldName
|
||||
}
|
||||
fType := registry.GetStrFromMap(f, "type")
|
||||
required, _ := f["required"].(bool)
|
||||
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
|
||||
if required {
|
||||
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
|
||||
}
|
||||
optStr := formatOptions(f)
|
||||
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
|
||||
desc := registry.GetStrFromMap(f, "description")
|
||||
if desc != "" {
|
||||
desc = util.TruncateStrWithEllipsis(desc, 100)
|
||||
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
|
||||
}
|
||||
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
|
||||
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
|
||||
}
|
||||
if rangeStr := formatRange(f); rangeStr != "" {
|
||||
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
|
||||
}
|
||||
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
|
||||
printNestedFields(w, props, indent+" ", fullName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
|
||||
func formatOptions(f map[string]interface{}) string {
|
||||
opts, ok := f["options"].([]interface{})
|
||||
if !ok || len(opts) == 0 {
|
||||
return ""
|
||||
}
|
||||
var vals []string
|
||||
for _, o := range opts {
|
||||
if om, ok := o.(map[string]interface{}); ok {
|
||||
if v := registry.GetStrFromMap(om, "value"); v != "" {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
|
||||
}
|
||||
|
||||
// formatRange returns "min..max" if field has min/max, else "".
|
||||
func formatRange(f map[string]interface{}) string {
|
||||
minVal := registry.GetStrFromMap(f, "min")
|
||||
maxVal := registry.GetStrFromMap(f, "max")
|
||||
if minVal == "" && maxVal == "" {
|
||||
return ""
|
||||
}
|
||||
if minVal != "" && maxVal != "" {
|
||||
return minVal + ".." + maxVal
|
||||
}
|
||||
if minVal != "" {
|
||||
return ">=" + minVal
|
||||
}
|
||||
return "<=" + maxVal
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in alphabetical order.
|
||||
func sortedKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
|
||||
func sortedParamKeys(params map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
pi, _ := params[keys[i]].(map[string]interface{})
|
||||
pj, _ := params[keys[j]].(map[string]interface{})
|
||||
ri, _ := pi["required"].(bool)
|
||||
rj, _ := pj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
|
||||
func sortedFieldKeys(fields map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
fi, _ := fields[keys[i]].(map[string]interface{})
|
||||
fj, _ := fields[keys[j]].(map[string]interface{})
|
||||
ri, _ := fi["required"].(bool)
|
||||
rj, _ := fj["required"].(bool)
|
||||
if ri != rj {
|
||||
return ri
|
||||
}
|
||||
return keys[i] < keys[j]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
|
||||
for i := len(parts); i >= 1; i-- {
|
||||
candidateName := strings.Join(parts[:i], ".")
|
||||
if res, ok := resources[candidateName]; ok {
|
||||
if resMap, ok := res.(map[string]interface{}); ok {
|
||||
return resMap, candidateName, parts[i:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", nil
|
||||
// Args are the positional path segments, in either the dotted single-arg
|
||||
// form ("im.messages.reply") or the space-separated form ("im messages
|
||||
// reply"); apicatalog.ParsePath normalizes both.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
|
||||
@@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Args = append([]string(nil), args...)
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// Tolerated for agent compatibility; ignored — schema only emits the JSON
|
||||
// envelope, and its output is identity-independent (strict-mode filtering
|
||||
// comes from ResolveStrictMode, never from --as).
|
||||
cmd.Flags().String("format", "json", "")
|
||||
cmd.Flags().Bool("json", true, "")
|
||||
cmd.Flags().String("as", "", "")
|
||||
_ = cmd.Flags().MarkHidden("format")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
_ = cmd.Flags().MarkHidden("as")
|
||||
|
||||
cmd.ValidArgsFunction = completeSchemaPath(f)
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
||||
// It uses the embedded source so completion candidates match what `schema`
|
||||
// execution can resolve (both overlay-free).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if noSpace {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
return completions, directive
|
||||
}
|
||||
}
|
||||
|
||||
func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string {
|
||||
var completions []string
|
||||
|
||||
for resName, resVal := range resources {
|
||||
if strings.HasPrefix(resName, afterService) {
|
||||
completions = append(completions, serviceName+"."+resName+".")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(afterService, resName+".") {
|
||||
continue
|
||||
}
|
||||
methodPrefix := afterService[len(resName)+1:]
|
||||
resMap, _ := resVal.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
for methodName := range methods {
|
||||
if strings.HasPrefix(methodName, methodPrefix) {
|
||||
completions = append(completions, serviceName+"."+resName+"."+methodName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(completions)
|
||||
return completions
|
||||
}
|
||||
|
||||
func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
// runSchema resolves the path through the embedded catalog and renders the
|
||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||
// output shape — a single resolved method renders as one envelope object,
|
||||
// anything broader as an array — and maps resolve failures to hints.
|
||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
catalog := registry.EmbeddedCatalog()
|
||||
target, err := catalog.Resolve(parts)
|
||||
if err != nil {
|
||||
return resolveError(err)
|
||||
}
|
||||
refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode))
|
||||
if target.Kind == apicatalog.TargetMethod {
|
||||
if len(refs) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"Method %s not available in current identity mode", target.Method.SchemaPath()).
|
||||
WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token")
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
output.PrintJson(out, schema.EnvelopeOf(refs[0]))
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
output.PrintJson(out, schema.Envelopes(refs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError
|
||||
// (CategoryValidation drives the exit code; Hint promotes to the envelope),
|
||||
// preserving the historical message + hint text.
|
||||
func resolveError(err error) error {
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
return err
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() {
|
||||
return spec
|
||||
}
|
||||
result := make(map[string]interface{}, len(spec))
|
||||
for k, v := range spec {
|
||||
result[k] = v
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return result
|
||||
}
|
||||
filteredRes := make(map[string]interface{}, len(resources))
|
||||
for resName, resVal := range resources {
|
||||
resMap, ok := resVal.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, _ := resMap["methods"].(map[string]interface{})
|
||||
filtered := filterMethodsByStrictMode(methods, mode)
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
}
|
||||
resCopy := make(map[string]interface{}, len(resMap))
|
||||
for k, v := range resMap {
|
||||
resCopy[k] = v
|
||||
}
|
||||
resCopy["methods"] = filtered
|
||||
filteredRes[resName] = resCopy
|
||||
}
|
||||
result["resources"] = filteredRes
|
||||
return result
|
||||
}
|
||||
|
||||
// filterMethodsByStrictMode removes methods incompatible with the active strict mode.
|
||||
// Returns the original map unmodified when strict mode is off.
|
||||
func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
if !mode.IsActive() || methods == nil {
|
||||
return methods
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
filtered := make(map[string]interface{}, len(methods))
|
||||
for name, val := range methods {
|
||||
m, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tokens, _ := m["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
filtered[name] = val
|
||||
continue
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == token {
|
||||
filtered[name] = val
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
switch re.Kind {
|
||||
case apicatalog.ErrService:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrResource:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrMethod:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject).
|
||||
WithHint("Available: %s", strings.Join(re.Candidates, ", "))
|
||||
case apicatalog.ErrPath:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject).
|
||||
WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,29 +20,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
|
||||
cmd.SetArgs([]string{"calendar.events.list"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts.Path != "calendar.events.list" {
|
||||
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
|
||||
}
|
||||
if gotOpts.Format != "pretty" {
|
||||
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
|
||||
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
|
||||
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
|
||||
// Agents are habituated to --format/--json/--as from api/service commands.
|
||||
// schema must accept them without erroring and always emit the JSON envelope —
|
||||
// its output is structured JSON and identity-independent, so the values have
|
||||
// no effect.
|
||||
argSets := [][]string{
|
||||
{"--format", "json"},
|
||||
{"--format", "pretty"},
|
||||
{"--format", "table"}, // no table rendering for a nested schema -> JSON
|
||||
{"--format", "csv"},
|
||||
{"--json"},
|
||||
{"--json", "--format", "ndjson"},
|
||||
{"--as", "user"},
|
||||
{"--as", "bot"},
|
||||
{"--as", "user", "--json"},
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list in pretty mode")
|
||||
for _, extra := range argSets {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +67,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -76,7 +92,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
cmd.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -179,23 +195,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -212,168 +211,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_FileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"description": "Upload an image",
|
||||
"requestBody": map[string]interface{}{
|
||||
"image_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "file",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "images", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "file upload") {
|
||||
t.Errorf("expected 'file upload' marker in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file") {
|
||||
t.Errorf("expected '--file' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"image"`) {
|
||||
t.Errorf("expected default field name 'image' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "--file <path>") {
|
||||
t.Errorf("expected CLI example with --file <path>, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintMethodDetail_NoFileUpload(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"name": "calendar",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
"path": "events",
|
||||
"httpMethod": "POST",
|
||||
"description": "Create an event",
|
||||
"requestBody": map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"type": "string",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
printMethodDetail(&buf, spec, "events", "create", method)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "file upload") {
|
||||
t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--file") {
|
||||
t.Errorf("did not expect '--file' for non-file method, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method map[string]interface{}
|
||||
wantBool bool
|
||||
wantFields []string
|
||||
}{
|
||||
{
|
||||
name: "has file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"image": map[string]interface{}{"type": "file"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: true,
|
||||
wantFields: []string{"image"},
|
||||
},
|
||||
{
|
||||
name: "no file field",
|
||||
method: map[string]interface{}{
|
||||
"requestBody": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
{
|
||||
name: "no requestBody",
|
||||
method: map[string]interface{}{},
|
||||
wantBool: false,
|
||||
wantFields: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, names := hasFileFields(tt.method)
|
||||
if got != tt.wantBool {
|
||||
t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool)
|
||||
}
|
||||
if tt.wantFields == nil && names != nil {
|
||||
t.Errorf("expected nil names, got %v", names)
|
||||
}
|
||||
if tt.wantFields != nil && len(names) != len(tt.wantFields) {
|
||||
t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSchemaPathForSpec(t *testing.T) {
|
||||
resources := map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
"record_permissions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := completeSchemaPathForSpec("base", resources, "records.cr")
|
||||
if len(got) != 1 || got[0] != "base.records.create" {
|
||||
t.Fatalf("completions = %v, want [base.records.create]", got)
|
||||
}
|
||||
|
||||
got = completeSchemaPathForSpec("base", resources, "record")
|
||||
if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." {
|
||||
t.Fatalf("resource completions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
"resources": map[string]interface{}{
|
||||
"records": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}},
|
||||
"create": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filtered := filterSpecByStrictMode(spec, core.StrictModeBot)
|
||||
resources, _ := filtered["resources"].(map[string]interface{})
|
||||
got := completeSchemaPathForSpec("base", resources, "records.")
|
||||
if len(got) != 1 || got[0] != "base.records.list" {
|
||||
t.Fatalf("filtered completions = %v, want [base.records.list]", got)
|
||||
}
|
||||
}
|
||||
// Completion candidate generation (dotted + space forms, strict-mode filtering,
|
||||
// dotted-resource handling) now lives in internal/apicatalog and is covered by
|
||||
// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra.
|
||||
|
||||
80
cmd/service/affordance.go
Normal file
80
cmd/service/affordance.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// methodLong composes a method command's long help in one place: the
|
||||
// description, the affordance guidance block (when the method has one), the
|
||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||
// the flag list.
|
||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||
// the method carries no affordance. It reads the single typed model
|
||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
if strings.TrimSpace(it) != "" {
|
||||
nonEmpty = append(nonEmpty, it)
|
||||
}
|
||||
}
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
}
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
if ex.Command == "" {
|
||||
continue
|
||||
}
|
||||
if ex.Description != "" {
|
||||
lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(" • %s", ex.Command))
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
72
cmd/service/affordance_test.go
Normal file
72
cmd/service/affordance_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
{"description":"no command, skipped","command":""}
|
||||
],
|
||||
"related": ["im.messages.list"]
|
||||
}`)
|
||||
out := renderAffordance(meta.Method{Affordance: raw})
|
||||
for _, want := range []string{
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("renderAffordance missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "no command, skipped") {
|
||||
t.Errorf("example with empty command should be skipped:\n%s", out)
|
||||
}
|
||||
|
||||
// Absent or empty affordance renders nothing (so methods without an overlay
|
||||
// add nothing to their help).
|
||||
if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" {
|
||||
t.Error("empty affordance should render nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// A method with no affordance adds no guidance block.
|
||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||
if strings.Contains(cmd2.Long, "Examples:") {
|
||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||
}
|
||||
}
|
||||
211
cmd/service/flaggroups.go
Normal file
211
cmd/service/flaggroups.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Flag annotations the grouped service-method help renderer reads.
|
||||
const (
|
||||
flagGroupAnnotation = "lark_flag_group" // display group key
|
||||
flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters
|
||||
flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag
|
||||
|
||||
groupParams = "params" // typed path/query flags
|
||||
groupBody = "body" // --data, --file
|
||||
groupRaw = "raw" // --params
|
||||
groupExecution = "execution" // --as/--dry-run/--page-*/--yes
|
||||
groupOutput = "output" // --output/--format/--jq
|
||||
|
||||
subRequired = "required"
|
||||
subOptional = "optional"
|
||||
)
|
||||
|
||||
// serviceFlagGroupOrder is the display order + titles of the flag groups. API
|
||||
// Parameters carries only typed path/query flags; raw --params, request body and
|
||||
// execution/output controls each get their own group so an agent can tell the
|
||||
// distinct input kinds apart.
|
||||
var serviceFlagGroupOrder = []struct{ key, title string }{
|
||||
{groupParams, "API Parameters"},
|
||||
{groupBody, "Request Body"},
|
||||
{groupRaw, "Raw Parameter Input"},
|
||||
{groupExecution, "Execution"},
|
||||
{groupOutput, "Output"},
|
||||
}
|
||||
|
||||
// applyGroupedUsage installs the grouped usage renderer on a service method
|
||||
// cmd: local flags via the grouped renderer instead of cobra's flat Flags:
|
||||
// list; global (inherited) flags and the Risk/Tips sections appended by the
|
||||
// root help func are unaffected. Rendered by hand rather than via
|
||||
// cmd.SetUsageTemplate: cobra lazy-links text/template on the first
|
||||
// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName —
|
||||
// that disables the linker's method-level dead-code elimination and costs
|
||||
// ~19 MB of binary size.
|
||||
func applyGroupedUsage(cmd *cobra.Command) {
|
||||
cmd.SetUsageFunc(func(c *cobra.Command) error {
|
||||
w := c.OutOrStderr()
|
||||
fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine())
|
||||
if c.HasAvailableLocalFlags() {
|
||||
fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c))
|
||||
}
|
||||
if c.HasAvailableInheritedFlags() {
|
||||
fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func annotate(f *pflag.Flag, key string, vals []string) {
|
||||
if f.Annotations == nil {
|
||||
f.Annotations = map[string][]string{}
|
||||
}
|
||||
f.Annotations[key] = vals
|
||||
}
|
||||
|
||||
// tagFlagGroup records a flag's display group (no-op if the flag is absent).
|
||||
func tagFlagGroup(fs *pflag.FlagSet, name, group string) {
|
||||
if f := fs.Lookup(name); f != nil {
|
||||
annotate(f, flagGroupAnnotation, []string{group})
|
||||
}
|
||||
}
|
||||
|
||||
func annotationOf(f *pflag.Flag, key string) []string {
|
||||
if f.Annotations != nil {
|
||||
return f.Annotations[key]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagGroupOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func flagSubOf(f *pflag.Flag) string {
|
||||
if v := annotationOf(f, flagSubAnnotation); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderServiceFlagGroups renders the command's local flags into ordered,
|
||||
// titled groups; the API Parameters group is further split into Required /
|
||||
// Optional. It is the body of the usage func applyGroupedUsage installs.
|
||||
func renderServiceFlagGroups(cmd *cobra.Command) string {
|
||||
var b strings.Builder
|
||||
seen := map[*pflag.Flag]bool{}
|
||||
for _, g := range serviceFlagGroupOrder {
|
||||
flags := groupFlags(cmd, g.key, seen)
|
||||
if len(flags) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", g.title)
|
||||
if g.key == groupParams {
|
||||
writeSection(&b, " Required:", subFlags(flags, subRequired))
|
||||
writeSection(&b, " Optional:", subFlags(flags, subOptional))
|
||||
} else {
|
||||
writeSection(&b, "", flags)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
}
|
||||
// Anything untagged (e.g. -h/--help) goes last under "Other".
|
||||
var other []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || seen[f] {
|
||||
return
|
||||
}
|
||||
other = append(other, f)
|
||||
})
|
||||
if len(other) > 0 {
|
||||
fmt.Fprintln(&b, "Other:")
|
||||
writeSection(&b, "", other)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// groupFlags returns the visible local flags tagged with group key, marking them
|
||||
// seen so the trailing "Other" bucket only catches genuinely untagged flags.
|
||||
func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag {
|
||||
var flags []*pflag.Flag
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden || flagGroupOf(f) != key {
|
||||
return
|
||||
}
|
||||
flags = append(flags, f)
|
||||
seen[f] = true
|
||||
})
|
||||
return flags
|
||||
}
|
||||
|
||||
func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag {
|
||||
var out []*pflag.Flag
|
||||
for _, f := range flags {
|
||||
s := flagSubOf(f)
|
||||
// Untagged subgroup defaults to Optional so nothing is dropped.
|
||||
if s == sub || (s == "" && sub == subOptional) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSection prints an optional (sub)header and the flags, aligned in a
|
||||
// column, each flag row followed by its note lines indented under the usage.
|
||||
func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) {
|
||||
if len(flags) == 0 {
|
||||
return
|
||||
}
|
||||
if header != "" {
|
||||
fmt.Fprintf(b, "%s\n", header)
|
||||
}
|
||||
specs := make([]string, len(flags))
|
||||
maxSpec := 0
|
||||
for i, f := range flags {
|
||||
specs[i] = flagSpec(f)
|
||||
if len(specs[i]) > maxSpec {
|
||||
maxSpec = len(specs[i])
|
||||
}
|
||||
}
|
||||
for i, f := range flags {
|
||||
_, usage := pflag.UnquoteUsage(f)
|
||||
if showsDefault(f) {
|
||||
usage += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
}
|
||||
fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage))
|
||||
for _, note := range annotationOf(f, flagNoteAnnotation) {
|
||||
fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flagSpec is pflag's " --name type" / " -x, --name type" left column.
|
||||
func flagSpec(f *pflag.Flag) string {
|
||||
typeName, _ := pflag.UnquoteUsage(f)
|
||||
spec := " --" + f.Name
|
||||
if f.Shorthand != "" && f.ShorthandDeprecated == "" {
|
||||
spec = " -" + f.Shorthand + ", --" + f.Name
|
||||
}
|
||||
if typeName != "" {
|
||||
spec += " " + typeName
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// showsDefault mirrors pflag's "non-zero default" rule for the flag types these
|
||||
// commands use, so the grouped rendering shows the same "(default x)" hints as
|
||||
// cobra's flat list.
|
||||
func showsDefault(f *pflag.Flag) bool {
|
||||
switch f.DefValue {
|
||||
case "", "0", "false", "[]":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
115
cmd/service/flaggroups_test.go
Normal file
115
cmd/service/flaggroups_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "chats/:chat_id/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"},
|
||||
map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Documented body field -> --data belongs under Request Body.
|
||||
"requestBody": map[string]interface{}{
|
||||
"id_list": map[string]interface{}{"type": "list", "required": true},
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
idx := func(s string) int { return strings.Index(out, s) }
|
||||
|
||||
// Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output.
|
||||
iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:")
|
||||
for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} {
|
||||
if i < 0 {
|
||||
t.Fatalf("missing section %q in:\n%s", name, out)
|
||||
}
|
||||
}
|
||||
if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) {
|
||||
t.Errorf("section order wrong:\n%s", out)
|
||||
}
|
||||
|
||||
// Required/Optional subsections under API Parameters.
|
||||
if i := idx(" Required:"); i < iParams || i > iBody {
|
||||
t.Errorf("Required subsection misplaced:\n%s", out)
|
||||
}
|
||||
if i := idx(" Optional:"); i < iParams || i > iBody {
|
||||
t.Errorf("Optional subsection misplaced:\n%s", out)
|
||||
}
|
||||
|
||||
// Typed flags are API Parameters; required path flag under Required, enum
|
||||
// flag under Optional with an inline "enum: ..." (not multi-line meanings).
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
}
|
||||
|
||||
// --data is Request Body; --params is Raw Parameter Input (NOT API Parameters)
|
||||
// and carries the precedence rule.
|
||||
if i := idx("--data"); i < iBody || i > iRaw {
|
||||
t.Errorf("--data not under Request Body:\n%s", out)
|
||||
}
|
||||
if i := idx("--params"); i < iRaw || i > iExec {
|
||||
t.Errorf("--params not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "typed flags override matching keys in --params") {
|
||||
t.Errorf("missing --params precedence rule:\n%s", out)
|
||||
}
|
||||
|
||||
// Control flags land in Execution/Output.
|
||||
if i := idx("--dry-run"); i < iExec || i > iOut {
|
||||
t.Errorf("--dry-run not under Execution:\n%s", out)
|
||||
}
|
||||
if idx("--format") < iOut {
|
||||
t.Errorf("--format not under Output:\n%s", out)
|
||||
}
|
||||
|
||||
// The usage template is wired to the grouped renderer (no flat Flags: list).
|
||||
if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") {
|
||||
t.Errorf("usage template not grouped:\n%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body
|
||||
// fields still offers --data (escape hatch) but must NOT imply a declared body —
|
||||
// it goes under Raw Parameter Input, not "Request Body".
|
||||
func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) {
|
||||
method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil)
|
||||
out := renderServiceFlagGroups(cmd)
|
||||
|
||||
if strings.Contains(out, "Request Body:") {
|
||||
t.Errorf("undocumented body must not render a Request Body section:\n%s", out)
|
||||
}
|
||||
iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data")
|
||||
if iRaw < 0 || iData < iRaw {
|
||||
t.Errorf("--data not under Raw Parameter Input:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "no documented fields") {
|
||||
t.Errorf("--data should be labeled a raw escape hatch:\n%s", out)
|
||||
}
|
||||
}
|
||||
166
cmd/service/paramflags.go
Normal file
166
cmd/service/paramflags.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type boundParamFlag struct {
|
||||
field meta.Field
|
||||
read func() interface{}
|
||||
}
|
||||
|
||||
// paramsOnlyField is a path/query parameter that got no typed flag because its
|
||||
// kebab name is already taken by another flag (a standard flag like --format, or
|
||||
// a root persistent flag). It stays reachable via --params; the binder keeps it,
|
||||
// with the flag that claimed the name, so --help can show the exact --params form
|
||||
// and steer the reader off the wrong flag.
|
||||
type paramsOnlyField struct {
|
||||
field meta.Field
|
||||
claimed *pflag.Flag
|
||||
}
|
||||
|
||||
// paramFlagBinder owns one service method's generated typed param flags: it
|
||||
// registers them (kind, help, enum completion, reserved-name skip) and applies
|
||||
// the --params overlay, where a changed typed flag overrides its key in the
|
||||
// --params JSON. Holding the field<->flag binding here keeps the request builder
|
||||
// from re-deriving which flags map to which param keys.
|
||||
type paramFlagBinder struct {
|
||||
bound []boundParamFlag
|
||||
paramsOnly []paramsOnlyField
|
||||
}
|
||||
|
||||
// newParamFlagBinder registers one typed kebab flag per path/query parameter on
|
||||
// cmd and returns a binder for the --params overlay. A name already taken by
|
||||
// another flag is skipped — pflag panics on a local duplicate and a generated
|
||||
// flag would silently shadow a persistent one — and recorded as paramsOnly so
|
||||
// the parameter stays reachable (and discoverable) via --params. The taken set
|
||||
// is derived, not hand-listed: local flags (the standard set, registered before
|
||||
// this runs) via cmd, the lazily-added --help materialized here, and the root's
|
||||
// persistent flags via reserved (nil for direct callers that have no root).
|
||||
func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder {
|
||||
cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it
|
||||
b := ¶mFlagBinder{}
|
||||
for _, f := range params {
|
||||
name := f.FlagName()
|
||||
if claimed := flagClaiming(cmd, reserved, name); claimed != nil {
|
||||
b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed})
|
||||
continue
|
||||
}
|
||||
read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f))
|
||||
if values := enumStrings(f.EnumValues()); len(values) > 0 {
|
||||
cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return values, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
// Group as an API parameter and mark required/optional for the
|
||||
// Required/Optional subsections of the grouped --help renderer.
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupParams})
|
||||
sub := subOptional
|
||||
if f.Required {
|
||||
sub = subRequired
|
||||
}
|
||||
annotate(fl, flagSubAnnotation, []string{sub})
|
||||
}
|
||||
b.bound = append(b.bound, boundParamFlag{field: f, read: read})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// flagClaiming returns the flag already occupying name (so a typed param flag
|
||||
// would collide), or nil when the name is free. It checks the command's own
|
||||
// flags (the standard set + the materialized --help) and the root's persistent
|
||||
// flags — so the reserved set is whatever is actually registered, never a
|
||||
// hand-kept list that drifts when a global flag is added.
|
||||
func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag {
|
||||
if fl := cmd.Flags().Lookup(name); fl != nil {
|
||||
return fl
|
||||
}
|
||||
if reserved != nil {
|
||||
return reserved.Lookup(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// paramsOnlyHelp renders the --help addendum for parameters that have no typed
|
||||
// flag, or "" when there are none. Per field: a copy-pasteable --params form,
|
||||
// the same fieldFacts a typed flag would show on its usage line, and what the
|
||||
// colliding flag actually does — so neither a human nor an agent sets the
|
||||
// wrong one (e.g. --format, which is the output format, not the API parameter).
|
||||
func (b *paramFlagBinder) paramsOnlyHelp() string {
|
||||
if len(b.paramsOnly) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n")
|
||||
for _, p := range b.paramsOnly {
|
||||
name := p.field.Name
|
||||
fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field))
|
||||
for _, fact := range fieldFacts(p.field) {
|
||||
fmt.Fprintf(&sb, " %s\n", fact)
|
||||
}
|
||||
if p.claimed != nil {
|
||||
fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// hasTypedFlag reports whether the binder registered a typed flag for the
|
||||
// param named name. False for params-only fields — a flag with the same kebab
|
||||
// name may exist (that's the collision), but it is not this param's input.
|
||||
// Nil-safe for direct buildServiceRequest callers that have no binder.
|
||||
func (b *paramFlagBinder) hasTypedFlag(name string) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if pf.field.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// overlay lets an explicit typed flag override the same key in --params
|
||||
// (--params is the base). Only changed flags apply, so the --params-only path is
|
||||
// unchanged. A nil binder or cmd is a no-op.
|
||||
func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) {
|
||||
if b == nil || cmd == nil {
|
||||
return
|
||||
}
|
||||
for _, pf := range b.bound {
|
||||
if cmd.Flags().Changed(pf.field.FlagName()) {
|
||||
params[pf.field.Name] = pf.read()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerTypedFlag registers one flag of the given canonical JSON-Schema kind
|
||||
// and returns a reader for its parsed value; the kind→pflag-type switch lives
|
||||
// only here.
|
||||
func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} {
|
||||
switch kind {
|
||||
case "integer":
|
||||
return flagReader(fs.Int(name, 0, usage))
|
||||
case "boolean":
|
||||
return flagReader(fs.Bool(name, false, usage))
|
||||
case "array":
|
||||
return flagReader(fs.StringArray(name, nil, usage))
|
||||
default:
|
||||
return flagReader(fs.String(name, "", usage))
|
||||
}
|
||||
}
|
||||
|
||||
func flagReader[T any](p *T) func() interface{} {
|
||||
return func() interface{} { return *p }
|
||||
}
|
||||
626
cmd/service/paramflags_test.go
Normal file
626
cmd/service/paramflags_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one
|
||||
// optional enum query param — the canonical case from the screenshot feedback.
|
||||
func imChatMembersCreate() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "chats/{chat_id}/members",
|
||||
"httpMethod": "POST",
|
||||
"parameters": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
"member_id_type": map[string]interface{}{
|
||||
"type": "string", "location": "query", "required": false,
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "open_id"},
|
||||
map[string]interface{}{"value": "user_id"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlagRegistered(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
|
||||
if cmd.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag for path param chat_id")
|
||||
}
|
||||
if cmd.Flags().Lookup("member-id-type") == nil {
|
||||
t.Error("expected generated --member-id-type flag for query param member_id_type")
|
||||
}
|
||||
}
|
||||
|
||||
// A query param literally named "format" kebab-collides with the global
|
||||
// --format flag. Generation must skip it (never re-register, never panic) and
|
||||
// leave the standard --format flag intact.
|
||||
func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
}
|
||||
|
||||
var cmd *cobra.Command
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("flag generation panicked on reserved-name collision: %v", r)
|
||||
}
|
||||
}()
|
||||
cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
}()
|
||||
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("standard --format flag must be preserved, got %+v", fl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") {
|
||||
t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("same value via flag and --params should be accepted, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --params is the base; an explicit typed flag overrides the same key.
|
||||
func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "chats/oc_flag/members") {
|
||||
t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "oc_params") {
|
||||
t.Errorf("--params value should have been overridden by the flag, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Override works for a non-string (integer) param too, exercising the int
|
||||
// register/read path end to end.
|
||||
func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") {
|
||||
t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: with no typed flags passed, behavior is byte-identical to today.
|
||||
func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null is valid JSON that unmarshals to a nil map. A typed
|
||||
// flag overlaying onto it must not panic (assignment to a nil map) — null is
|
||||
// treated as "no base params", with the flag value applied on top.
|
||||
func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with a typed flag should not error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "chats/oc_abc123/members") {
|
||||
t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Startup smoke test: registering every embedded method must not panic on a
|
||||
// generated-flag name collision (pflag panics on duplicate registration, which
|
||||
// would crash the whole CLI at startup), and a known path param must surface as
|
||||
// a typed flag end to end.
|
||||
func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("registering all service commands panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
RegisterServiceCommands(root, f)
|
||||
|
||||
create, _, err := root.Find([]string{"im", "chat.members", "create"})
|
||||
if err != nil {
|
||||
t.Fatalf("im chat.members create not registered: %v", err)
|
||||
}
|
||||
if create.Flags().Lookup("chat-id") == nil {
|
||||
t.Error("expected generated --chat-id flag on im chat.members create")
|
||||
}
|
||||
}
|
||||
|
||||
// Locks the boolean and array branches of bindParamFlag end to end (string and
|
||||
// integer are covered above): a bool flag yields true and a repeatable array
|
||||
// flag yields all its elements in the request.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override (--params base, typed flag wins) is covered for string and integer
|
||||
// above; this locks the same semantics for the boolean and array kinds.
|
||||
func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
"ids": map[string]interface{}{"type": "list", "location": "query"},
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"with_deleted":false,"ids":["from_params"]}`,
|
||||
"--with-deleted", "--ids", "a", "--ids", "b",
|
||||
"--dry-run",
|
||||
})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "from_params") {
|
||||
t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A param whose kebab name collides with a global flag (here "format" vs the
|
||||
// global --format) gets no typed flag, but the collision is no longer silent:
|
||||
// non-colliding params still get flags, the global --format is untouched, and
|
||||
// --help shows the exact --params form and steers the reader off --format.
|
||||
func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"path": "things/{thing_id}",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{
|
||||
map[string]interface{}{"value": "full"},
|
||||
map[string]interface{}{"value": "metadata"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil)
|
||||
|
||||
if cmd.Flags().Lookup("thing-id") == nil {
|
||||
t.Error("non-colliding param should still get a typed --thing-id flag")
|
||||
}
|
||||
if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" {
|
||||
t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl)
|
||||
}
|
||||
for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The collision guard derives reserved names from the actual flag sets — local
|
||||
// flags plus the root's persistent flags passed in — so a future persistent
|
||||
// flag is covered with no hand-maintained list. Here a param named "profile"
|
||||
// (a root persistent flag) is skipped while a normal param is bound.
|
||||
func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
reserved := pflag.NewFlagSet("root", pflag.ContinueOnError)
|
||||
reserved.String("profile", "", "use a specific profile")
|
||||
|
||||
m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"profile": map[string]interface{}{"type": "string", "location": "query"},
|
||||
"id": map[string]interface{}{"type": "string", "location": "path"},
|
||||
}})
|
||||
b := newParamFlagBinder(cmd, m.Params(), reserved)
|
||||
|
||||
if cmd.Flags().Lookup("id") == nil {
|
||||
t.Error("non-colliding param should get a typed flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("profile") != nil {
|
||||
t.Error("param colliding with a reserved persistent flag must not be registered")
|
||||
}
|
||||
found := false
|
||||
for _, p := range b.paramsOnly {
|
||||
if p.field.Name == "profile" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("colliding param should be recorded for the --params help note")
|
||||
}
|
||||
}
|
||||
|
||||
// boolIntQueryMethod is the fixture for the zero-value semantics tests: one
|
||||
// boolean and one integer query param, where false and 0 are meaningful values.
|
||||
func boolIntQueryMethod(required bool) meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required},
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
|
||||
// so --flag=false / --flag 0 are real values and must be sent — not silently
|
||||
// dropped as "empty", which would let the API default win over an explicit
|
||||
// user choice.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly provided false satisfies a required query parameter — the
|
||||
// pre-flight must not report "missing" for a value the user just set.
|
||||
func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"with_deleted": false`) {
|
||||
t.Errorf("explicit false must be sent, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The same presence-is-intent rule applies to the --params JSON base: a key
|
||||
// deliberately written as false/0 is sent. (Zero values used to be silently
|
||||
// dropped; this locks the corrected semantics as the contract.)
|
||||
func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "" stays unusable: a required parameter fed an empty-string placeholder is
|
||||
// still caught by the friendly pre-flight error, not sent as an empty value.
|
||||
func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required query parameter") {
|
||||
t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A declared optional query param fed "" is dropped (unusable value), not sent
|
||||
// as an empty query value — the declared-param loop owns the decision and the
|
||||
// undeclared passthrough must not resurrect it. Undeclared keys stay the
|
||||
// verbatim raw escape hatch.
|
||||
func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"user_id_type": map[string]interface{}{"type": "string", "location": "query"},
|
||||
},
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if strings.Contains(out, "user_id_type") {
|
||||
t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"custom_key": "v1"`) {
|
||||
t.Errorf("undeclared key must pass through verbatim, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// min/max from the metadata surface on the typed flag's help line, in the same
|
||||
// vocabulary as the envelope's minimum/maximum.
|
||||
func TestParamFlagUsage_Bounds(t *testing.T) {
|
||||
cases := []struct{ name, min, max, want string }{
|
||||
{"both", "1", "100", "min: 1, max: 100"},
|
||||
{"min only", "1", "", "min: 1"},
|
||||
{"max only", "", "64", "max: 64"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) {
|
||||
t.Errorf("usage = %q, want contains %q", usage, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("no bounds, no clause", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"page_token": map[string]interface{}{"type": "string", "location": "query"},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") {
|
||||
t.Errorf("usage without bounds should not mention min/max, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The sanitized field description rides the help line — a bare name like
|
||||
// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT
|
||||
// at sentence ends (。): the later sentence often holds the key affordance.
|
||||
func TestParamFlagUsage_Description(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"user_mailbox_id": map[string]interface{}{
|
||||
"type": "string", "location": "path", "required": true,
|
||||
"description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`,
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) {
|
||||
t.Errorf("description must keep full sentences up to the note separator, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, "补充说明") {
|
||||
t.Errorf("text after the note separator must be cut, got %q", usage)
|
||||
}
|
||||
|
||||
t.Run("long description truncated", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": strings.Repeat("长", 80),
|
||||
},
|
||||
}}).Params()
|
||||
usage := paramFlagUsage(fields[0])
|
||||
if !strings.Contains(usage, "...") {
|
||||
t.Errorf("long description should be truncated with ellipsis, got %q", usage)
|
||||
}
|
||||
if strings.Contains(usage, strings.Repeat("长", 61)) {
|
||||
t.Errorf("description should not exceed the cap, got %q", usage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing sentence punctuation trimmed", func(t *testing.T) {
|
||||
fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"x": map[string]interface{}{
|
||||
"type": "string", "location": "query", "description": "返回格式。",
|
||||
},
|
||||
}}).Params()
|
||||
if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") {
|
||||
t.Errorf("clause join must not double the punctuation, got %q", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Pins the convergence contract: the params-only addendum renders the SAME
|
||||
// fieldFacts list the typed flag's usage line joins inline — a fact added to
|
||||
// fieldFacts reaches both surfaces, and neither can drift over what a param's
|
||||
// help says (the addendum once rendered values-only enums and silently lacked
|
||||
// the API default).
|
||||
func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) {
|
||||
f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{
|
||||
"mode": map[string]interface{}{
|
||||
"type": "string", "location": "query",
|
||||
"description": "模式选择。",
|
||||
"default": "fast",
|
||||
"min": "1", "max": "8",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "fast", "description": "快速"},
|
||||
map[string]interface{}{"value": "full"},
|
||||
},
|
||||
},
|
||||
}}).Params()[0]
|
||||
|
||||
facts := fieldFacts(f)
|
||||
if len(facts) != 4 { // description, enum, bounds, API default
|
||||
t.Fatalf("fieldFacts = %v, want 4 facts", facts)
|
||||
}
|
||||
usage := paramFlagUsage(f)
|
||||
help := (¶mFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp()
|
||||
for _, fact := range facts {
|
||||
if !strings.Contains(usage, fact) {
|
||||
t.Errorf("usage line missing fact %q: %q", fact, usage)
|
||||
}
|
||||
if !strings.Contains(help, fact) {
|
||||
t.Errorf("params-only addendum missing fact %q:\n%s", fact, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds reach the registered flag's help end to end.
|
||||
func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"},
|
||||
},
|
||||
})
|
||||
cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil)
|
||||
fl := cmd.Flags().Lookup("page-size")
|
||||
if fl == nil {
|
||||
t.Fatal("expected generated --page-size flag")
|
||||
}
|
||||
if !strings.Contains(fl.Usage, "min: 1, max: 100") {
|
||||
t.Errorf("flag usage should carry bounds, got %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// The missing-required hint must name both recovery paths — the typed flag and
|
||||
// the --params fallback — so a reader who only knows one input style can
|
||||
// proceed without a round-trip through schema.
|
||||
func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
|
||||
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`, "lark-cli schema im.chat.members.create"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint %q should contain %q", ve.Hint, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A params-only required field (kebab name claimed by the standard --format
|
||||
// flag) has no typed flag to offer: the hint must give only the --params form,
|
||||
// never steer the reader to the colliding flag.
|
||||
func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) {
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "messages",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"format": map[string]interface{}{"type": "string", "location": "query", "required": true},
|
||||
},
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil)
|
||||
cmd.SetArgs([]string{"--dry-run"})
|
||||
|
||||
err := cmd.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, `--params '{"format": "<value>"}'`) {
|
||||
t.Errorf("hint %q should carry the --params form", ve.Hint)
|
||||
}
|
||||
if strings.Contains(ve.Hint, "set --format") {
|
||||
t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint)
|
||||
}
|
||||
}
|
||||
162
cmd/service/paramhelp.go
Normal file
162
cmd/service/paramhelp.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Help rendering for generated param flags. fieldFacts is the single list of
|
||||
// agent-relevant facts a param exposes; every help surface (the typed flag's
|
||||
// usage line, the params-only --params addendum) renders that one list, so the
|
||||
// surfaces cannot drift over which facts exist. Values come from the
|
||||
// meta.Field accessors, so nothing here depends on internal/schema.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// fieldFacts returns a param field's facts in display order, each as a compact
|
||||
// one-line clause: the sanitized description, the allowed enum values (with
|
||||
// meanings), the min/max constraint, and the API default. This is the ONE
|
||||
// place that decides what a param's help says — add a fact here (e.g. a future
|
||||
// deprecation marker) and every surface shows it. Unabridged prose and
|
||||
// per-option detail stay in `lark-cli schema`.
|
||||
func fieldFacts(f meta.Field) []string {
|
||||
var facts []string
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
if b := formatBoundsInline(f); b != "" {
|
||||
facts = append(facts, b)
|
||||
}
|
||||
if s := literalStr(f.CoercedDefault()); s != "" {
|
||||
facts = append(facts, "API default: "+s)
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line:
|
||||
//
|
||||
// <param_name>, required|optional[. <fact>]...
|
||||
//
|
||||
// It leads with the canonical underscore param name (the key this flag
|
||||
// overrides in --params) and required/optional, then joins the field's facts
|
||||
// inline.
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
// its first allowed enum value, else its example, else a placeholder.
|
||||
func paramExample(f meta.Field) string {
|
||||
if vals := enumStrings(f.EnumValues()); len(vals) > 0 {
|
||||
return fmt.Sprintf("%q", vals[0])
|
||||
}
|
||||
if s := literalStr(f.CoercedExample()); s != "" {
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
return `"<value>"`
|
||||
}
|
||||
|
||||
var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
|
||||
|
||||
// inlineClause compresses metadata prose into one help clause: markdown links
|
||||
// keep their text, the clause cuts at the first rune in stops, whitespace
|
||||
// collapses, trailing punctuation goes — sentence enders (the clause join adds
|
||||
// its own) and connectors a cut can strand, like a colon introducing a list the
|
||||
// newline cut dropped — and the result caps at max runes. The two policies
|
||||
// below differ only in where they cut and how much they keep.
|
||||
func inlineClause(s, stops string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = markdownLinkRe.ReplaceAllString(s, "$1")
|
||||
// Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a
|
||||
// flag's usage string as the flag's metavar, so a description like wiki
|
||||
// space_id's "可替换为`my_library`" would render the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
s = strings.ReplaceAll(s, "`", "")
|
||||
if i := strings.IndexAny(s, stops); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.TrimRight(s, "。.::,,、")
|
||||
return util.TruncateStrWithEllipsis(s, max)
|
||||
}
|
||||
|
||||
// sanitizeOptionDesc is the enum-option policy: many values share one line, so
|
||||
// keep only the first clause (cut at 。 too) and stay ultra-compact.
|
||||
func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) }
|
||||
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings
|
||||
// live in the envelope's enumDescriptions / `lark-cli schema`.
|
||||
func formatEnumInline(opts []meta.EnumOption) string {
|
||||
items := make([]string, len(opts))
|
||||
for i, o := range opts {
|
||||
if d := sanitizeOptionDesc(o.Description); d != "" {
|
||||
items[i] = fmt.Sprintf("%v=%s", o.Value, d)
|
||||
} else {
|
||||
items[i] = fmt.Sprintf("%v", o.Value)
|
||||
}
|
||||
}
|
||||
return strings.Join(items, "|")
|
||||
}
|
||||
|
||||
// formatBoundsInline renders the field's min/max constraint ("min: 1, max:
|
||||
// 100", or the single declared side), or "" when the field declares neither.
|
||||
// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli
|
||||
// schema` state the same constraint.
|
||||
func formatBoundsInline(f meta.Field) string {
|
||||
min, max := f.MinBound(), f.MaxBound()
|
||||
switch {
|
||||
case min != nil && max != nil:
|
||||
return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max))
|
||||
case min != nil:
|
||||
return "min: " + formatBound(*min)
|
||||
case max != nil:
|
||||
return "max: " + formatBound(*max)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatBound renders a bound without a float artifact (100 not 100.000000).
|
||||
func formatBound(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// literalStr renders a coerced literal (default/example) for flag help,
|
||||
// returning "" for a nil or empty value so the caller can omit the clause.
|
||||
func literalStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
func enumStrings(enum []interface{}) []string {
|
||||
out := make([]string, 0, len(enum))
|
||||
for _, e := range enum {
|
||||
out = append(out, fmt.Sprintf("%v", e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
61
cmd/service/sanitize_test.go
Normal file
61
cmd/service/sanitize_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeOptionDesc(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"以 open_id 标识用户": "以 open_id 标识用户",
|
||||
"中文。English second clause": "中文", // first clause only (。)
|
||||
"head;tail": "head", // first clause (;)
|
||||
"line one\nline two": "line one", // first clause (newline)
|
||||
" spaced out ": "spaced out", // whitespace collapsed
|
||||
"see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeOptionDesc(in); got != want {
|
||||
t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Truncation: a long single clause is cut to 40 runes with an ellipsis,
|
||||
// rune-safe (no split mid-character).
|
||||
long := strings.Repeat("文", 60)
|
||||
got := sanitizeOptionDesc(long)
|
||||
if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") {
|
||||
t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) {
|
||||
// A clause cut can strand a connector (e.g. a colon introducing a list the
|
||||
// newline cut drops, as in im.reactions.list's message_id); the help line
|
||||
// joiner then renders "…获取方式:." — so dangling punctuation must go too.
|
||||
cases := map[string]string{
|
||||
"待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式",
|
||||
"see the list below:\nitem": "see the list below",
|
||||
"逗号结尾,\n下一行": "逗号结尾",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) {
|
||||
// pflag's UnquoteUsage takes a backquoted word in a flag's usage string as
|
||||
// the flag's metavar: wiki space_id's description rendered the flag as
|
||||
// "--space-id my_library" instead of "--space-id string".
|
||||
in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`"
|
||||
want := "知识空间id,如果查询我的文档库可替换为my_library"
|
||||
if got := sanitizeFieldDesc(in); got != want {
|
||||
t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RegisterServiceCommands registers all service commands from from_meta specs.
|
||||
@@ -30,85 +32,74 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
// Drive the service list from the same navigation catalog the method walk
|
||||
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
|
||||
// merged metadata — so registration is catalog-sourced end to end. Kept as a
|
||||
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
|
||||
// service with no methods still gets its bare command (WalkMethods yields one
|
||||
// ref per method, so empty services would vanish).
|
||||
for _, svc := range registry.RuntimeCatalog().Services() {
|
||||
if svc.Name == "" || svc.ServicePath == "" {
|
||||
continue
|
||||
}
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, svc, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
}
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) {
|
||||
svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc))
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
// Build the service's subtree from the catalog's method walk
|
||||
// (apicatalog.ServiceMethods recurses nested resources), so the command tree
|
||||
// is sourced from the same navigation Module as schema/scope rather than a
|
||||
// hand-rolled resource/method walk. Each ref's ResourcePath becomes the
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
resCmd := svcCmd
|
||||
for _, seg := range ref.ResourcePath {
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
if d := registry.GetServiceDescription(svc.Name, "en"); d != "" {
|
||||
return d
|
||||
}
|
||||
return svc.Description
|
||||
}
|
||||
|
||||
// ensureChildCommand returns the child of parent named name, creating it (with
|
||||
// short) when absent — so re-registration merges into an existing command tree
|
||||
// instead of duplicating a level.
|
||||
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == specName {
|
||||
svc = c
|
||||
break
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
}
|
||||
cmd := &cobra.Command{Use: name, Short: short}
|
||||
parent.AddCommand(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
|
||||
type ServiceMethodOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
Spec map[string]interface{}
|
||||
Method map[string]interface{}
|
||||
SchemaPath string
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ctx context.Context
|
||||
ServicePath string
|
||||
Method meta.Method
|
||||
SchemaPath string
|
||||
|
||||
// Flags
|
||||
Params string
|
||||
@@ -123,41 +114,113 @@ type ServiceMethodOptions struct {
|
||||
DryRun bool
|
||||
File string // --file flag value
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
|
||||
// binder owns the generated typed param flags — registration and the
|
||||
// --params overlay — replacing the raw paramFlags side-channel.
|
||||
binder *paramFlagBinder
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
// detectFileFields returns the request-body file-upload field names.
|
||||
func detectFileFields(m meta.Method) []string {
|
||||
files := m.Files()
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
// NewCmdServiceMethodWithContext builds the command for one service method from
|
||||
// its (service, resource, method) coordinates, deriving the methodCommandSpec
|
||||
// via an apicatalog.MethodRef so direct callers and the catalog-driven
|
||||
// registration assemble the command identically.
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
m.Name = name
|
||||
ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m}
|
||||
// No root in scope here; persistent-flag collisions don't apply to a
|
||||
// standalone command, and local/standard-flag collisions are still caught.
|
||||
return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil)
|
||||
}
|
||||
|
||||
// methodCommandSpec is the static description of one generated service method
|
||||
// command, read off an apicatalog.MethodRef — the single place command
|
||||
// construction gets the method's facts (schema path, HTTP base path, risk,
|
||||
// identities, params, file fields, request-body support), so the cobra command
|
||||
// is assembled from a typed spec rather than recomputing paths/flags inline.
|
||||
type methodCommandSpec struct {
|
||||
method meta.Method
|
||||
schemaPath string // "service.resource.method", for the --help hint
|
||||
servicePath string // service HTTP base path
|
||||
risk string // RiskRead | RiskWrite | RiskHighRiskWrite
|
||||
restricts bool // method declares accessTokens (identity-restricted)
|
||||
identities []string // permitted --as values; empty when unrestricted
|
||||
params []meta.Field // path/query params -> typed flags
|
||||
fileFields []string // request-body file-upload field names
|
||||
// acceptsBody is whether the HTTP method allows a request body at all (so
|
||||
// --data is offered as a raw escape hatch). declaresBody is whether the
|
||||
// metadata documents body fields (data or file). They differ for e.g. a POST
|
||||
// with no documented requestBody: --data still works, but help must not imply
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
m := ref.Method
|
||||
return methodCommandSpec{
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
params: m.Params(),
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
affordance: renderAffordance(m),
|
||||
}
|
||||
}
|
||||
|
||||
// methodTakesBody reports whether the HTTP method allows a request body, i.e.
|
||||
// whether --data applies (as a raw escape hatch even when no body is declared).
|
||||
func methodTakesBody(httpMethod string) bool {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildMethodCommand assembles the cobra command for a service method from its
|
||||
// static spec: the standard flags, the conditional --data/--file/--yes flags,
|
||||
// the generated typed param flags (via paramFlagBinder), and the risk/identity
|
||||
// policy annotations.
|
||||
func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command {
|
||||
m := spec.method
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
Factory: f,
|
||||
ServicePath: spec.servicePath,
|
||||
Method: m,
|
||||
SchemaPath: spec.schemaPath,
|
||||
FileFields: spec.fileFields,
|
||||
}
|
||||
var asStr string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: name,
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
Use: m.Name,
|
||||
Short: m.Description,
|
||||
// Long is assembled below, once the binder knows which params got no
|
||||
// typed flag.
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
@@ -169,10 +232,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)")
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)")
|
||||
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
|
||||
if spec.acceptsBody {
|
||||
dataUsage := "JSON request body. Supports - and @file."
|
||||
if !spec.declaresBody {
|
||||
// POST/etc. with no documented body fields: --data is a raw escape
|
||||
// hatch, not a declared body — say so rather than imply structure.
|
||||
dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file."
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage)
|
||||
}
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr)
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
|
||||
@@ -183,27 +251,61 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
if spec.risk == cmdutil.RiskHighRiskWrite {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)")
|
||||
}
|
||||
// --file only for body methods that actually declare file-type fields.
|
||||
if len(spec.fileFields) > 0 && spec.acceptsBody {
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
|
||||
}
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Single composition point for Long: description, affordance, schema
|
||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||
// taken, reachable via --params only).
|
||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
// registered above (e.g. --data/--file/--yes only exist for some methods).
|
||||
// --data sits under Request Body only when the metadata documents body
|
||||
// fields; otherwise it's a raw escape hatch, grouped with --params so help
|
||||
// doesn't imply a declared body the API doesn't have.
|
||||
if fl := cmd.Flags().Lookup("data"); fl != nil {
|
||||
if spec.declaresBody {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupBody})
|
||||
} else {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
}
|
||||
}
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
if len(spec.params) > 0 {
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupExecution)
|
||||
}
|
||||
for _, name := range []string{"output", "format", "jq"} {
|
||||
tagFlagGroup(cmd.Flags(), name, groupOutput)
|
||||
}
|
||||
applyGroupedUsage(cmd)
|
||||
|
||||
cmdutil.SetTips(cmd, m.Tips)
|
||||
cmdutil.SetRisk(cmd, spec.risk)
|
||||
if spec.restricts {
|
||||
cmdutil.SetSupportedIdentities(cmd, spec.identities)
|
||||
}
|
||||
|
||||
return cmd
|
||||
@@ -218,8 +320,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// Check if this API method supports the resolved identity.
|
||||
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
|
||||
if opts.Method.RestrictsIdentity() {
|
||||
if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -235,12 +337,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
// Identity is not printed to stderr here: it is part of the JSON envelope.
|
||||
|
||||
scopes, _ := opts.Method["scopes"].([]interface{})
|
||||
if !opts.As.IsBot() {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
|
||||
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -257,7 +357,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" {
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes {
|
||||
return cmdutil.RequireConfirmation(opts.SchemaPath)
|
||||
}
|
||||
@@ -302,7 +402,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -311,23 +411,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
|
||||
}
|
||||
|
||||
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
|
||||
|
||||
if hasRequired && len(requiredScopes) > 0 {
|
||||
if len(method.RequiredScopes) > 0 {
|
||||
// Strict: ALL requiredScopes must be present
|
||||
required := make([]string, 0, len(requiredScopes))
|
||||
for _, s := range requiredScopes {
|
||||
if str, ok := s.(string); ok {
|
||||
required = append(required, str)
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 {
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(scopes) == 0 {
|
||||
if len(method.Scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -336,12 +428,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
for _, s := range strings.Fields(result.Scopes) {
|
||||
grantedSet[s] = true
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok && grantedSet[str] {
|
||||
for _, s := range method.Scopes {
|
||||
if grantedSet[s] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user")
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
||||
}
|
||||
|
||||
@@ -362,14 +454,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri
|
||||
WithIdentity(identity)
|
||||
}
|
||||
|
||||
// unusableParamValue reports whether a provided path/query parameter value
|
||||
// cannot form a usable request value: nil or an empty string. A key's presence
|
||||
// in params is the intent signal — a typed flag is overlaid only when
|
||||
// explicitly Changed, and a --params JSON key is deliberately written — so
|
||||
// false and 0 are real values and must not be conflated with "unset"
|
||||
// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0).
|
||||
// Only nil/"" stay treated as missing: that keeps the friendly pre-flight
|
||||
// error when a required param is fed an empty placeholder, and never emits a
|
||||
// declared param as an empty path segment or query value. Undeclared keys are
|
||||
// not judged by this rule — they pass through verbatim as the raw escape hatch.
|
||||
func unusableParamValue(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return ok && s == ""
|
||||
}
|
||||
|
||||
// missingParamHint is the recovery hint for a missing required parameter. It
|
||||
// names both input paths — the typed flag when the binder registered one, and
|
||||
// the --params fallback — plus the schema pointer. A params-only field gets
|
||||
// only the --params form: a flag with its kebab name exists but belongs to
|
||||
// something else (e.g. the output --format), and the hint must not steer
|
||||
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
|
||||
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
|
||||
if opts.binder.hasTypedFlag(f.Name) {
|
||||
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
|
||||
}
|
||||
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
// When dryRun is true and a file is provided, file reading is skipped and
|
||||
// FileUploadMeta is returned instead so the caller can render dry-run output.
|
||||
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) {
|
||||
spec := opts.Spec
|
||||
method := opts.Method
|
||||
schemaPath := opts.SchemaPath
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
httpMethod := method.HTTPMethod
|
||||
|
||||
// stdin is an io.Reader consumed at most once. Only one of --params/--data
|
||||
// may use "-" (stdin); the conflict check below prevents silent data loss.
|
||||
@@ -387,53 +509,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
if err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
opts.binder.overlay(opts.Cmd, params)
|
||||
|
||||
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
|
||||
url := opts.ServicePath + "/" + method.Path
|
||||
|
||||
parameters, _ := method["parameters"].(map[string]interface{})
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "path" {
|
||||
specs := method.Params()
|
||||
for _, s := range specs {
|
||||
if s.Location != "path" {
|
||||
continue
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
val, ok := params[s.Name]
|
||||
if !ok || unusableParamValue(val) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
"missing required path parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
|
||||
if err := validate.ResourceName(valStr, s.Name); err != nil {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, s.Name)
|
||||
}
|
||||
|
||||
queryParams := map[string]interface{}{}
|
||||
for name, param := range parameters {
|
||||
p, _ := param.(map[string]interface{})
|
||||
if registry.GetStrFromMap(p, "location") != "query" {
|
||||
for _, s := range specs {
|
||||
if s.Location != "query" {
|
||||
continue
|
||||
}
|
||||
value, exists := params[name]
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
value, exists := params[s.Name]
|
||||
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
|
||||
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
"missing required query parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
}
|
||||
if exists && !util.IsEmptyValue(value) {
|
||||
queryParams[name] = value
|
||||
if exists && !unusableParamValue(value) {
|
||||
queryParams[s.Name] = value
|
||||
}
|
||||
// This loop owns declared query params: consume the key so the
|
||||
// passthrough below can't resurrect a value the gate dropped (an
|
||||
// unusable "" would otherwise be sent as an empty query value).
|
||||
delete(params, s.Name)
|
||||
}
|
||||
// Whatever remains is undeclared — the raw escape hatch for params the
|
||||
// metadata doesn't (yet) describe; passed through verbatim, no filtering.
|
||||
for name, value := range params {
|
||||
if _, ok := queryParams[name]; !ok {
|
||||
queryParams[name] = value
|
||||
}
|
||||
queryParams[name] = value
|
||||
}
|
||||
|
||||
request := client.RawApiRequest{
|
||||
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// highRiskDeleteMethod mirrors a simple DELETE API with a required path
|
||||
// parameter and risk metadata. The returned map is what service registration
|
||||
// reads; the test exercises --yes registration and the gate behavior.
|
||||
func highRiskDeleteMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
// parameter and risk metadata. The test exercises --yes registration and the
|
||||
// gate behavior.
|
||||
func highRiskDeleteMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"risk": "high-risk-write",
|
||||
@@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeMethodNoRisk() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func writeMethodNoRisk() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "files/{file_token}",
|
||||
"httpMethod": "DELETE",
|
||||
"parameters": map[string]interface{}{
|
||||
@@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} {
|
||||
"type": "string", "location": "path", "required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -20,14 +21,14 @@ var testConfig = &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
|
||||
func driveSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func driveSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "drive",
|
||||
"servicePath": "/open-apis/drive/v1",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
|
||||
func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
|
||||
m := map[string]interface{}{
|
||||
"path": "files/{file_token}/copy",
|
||||
"httpMethod": httpMethod,
|
||||
@@ -41,7 +42,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
},
|
||||
}
|
||||
}
|
||||
return m
|
||||
return meta.FromMap(m)
|
||||
}
|
||||
|
||||
// ── registerService ──
|
||||
@@ -49,23 +50,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
|
||||
func TestRegisterService(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
f := &cmdutil.Factory{}
|
||||
spec := map[string]interface{}{
|
||||
base := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "base",
|
||||
"description": "Base API",
|
||||
"servicePath": "/open-apis/base/v3",
|
||||
}
|
||||
resources := map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{
|
||||
"description": "List tables",
|
||||
"httpMethod": "GET",
|
||||
"resources": map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{
|
||||
"description": "List tables",
|
||||
"httpMethod": "GET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
registerService(parent, spec, resources, f)
|
||||
registerService(parent, base, f)
|
||||
|
||||
// service command exists
|
||||
svc, _, err := parent.Find([]string{"base"})
|
||||
@@ -90,18 +91,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) {
|
||||
parent.AddCommand(existing)
|
||||
|
||||
f := &cmdutil.Factory{}
|
||||
spec := map[string]interface{}{
|
||||
svc := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
|
||||
}
|
||||
resources := map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
||||
"resources": map[string]interface{}{
|
||||
"tables": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
registerService(parent, spec, resources, f)
|
||||
registerService(parent, svc, f)
|
||||
|
||||
// Should reuse existing, not duplicate
|
||||
count := 0
|
||||
@@ -143,7 +144,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) {
|
||||
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("data") != nil {
|
||||
t.Error("GET method should not have --data flag")
|
||||
@@ -159,7 +160,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
|
||||
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil)
|
||||
|
||||
if cmd.Flags().Lookup("data") == nil {
|
||||
t.Error("POST method should have --data flag")
|
||||
@@ -171,7 +172,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -268,15 +269,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"q": map[string]interface{}{"location": "query", "required": true},
|
||||
},
|
||||
}
|
||||
})
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
|
||||
@@ -291,15 +292,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"page_size": map[string]interface{}{"location": "query", "required": true},
|
||||
},
|
||||
}
|
||||
})
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
|
||||
@@ -315,10 +316,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
|
||||
|
||||
@@ -333,10 +334,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
|
||||
|
||||
@@ -351,10 +352,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
|
||||
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
|
||||
|
||||
@@ -369,10 +370,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
@@ -398,8 +399,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
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"})
|
||||
|
||||
@@ -427,8 +428,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
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"})
|
||||
|
||||
@@ -450,8 +451,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
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", "--format", "unknown"})
|
||||
|
||||
@@ -470,7 +471,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -492,7 +493,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
@@ -508,10 +509,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"})
|
||||
|
||||
@@ -542,8 +543,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
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", "--jq", ".data.items[].name"})
|
||||
|
||||
@@ -561,10 +562,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"})
|
||||
|
||||
@@ -579,10 +580,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) {
|
||||
|
||||
func TestServiceMethod_JqInvalidExpression(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := map[string]interface{}{
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "svc", "servicePath": "/open-apis/svc/v1",
|
||||
}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
|
||||
})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"})
|
||||
|
||||
@@ -611,8 +612,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
|
||||
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
|
||||
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"})
|
||||
|
||||
@@ -630,8 +631,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func imImageMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "images",
|
||||
"httpMethod": "POST",
|
||||
"requestBody": map[string]interface{}{
|
||||
@@ -645,14 +646,14 @@ func imImageMethod() map[string]interface{} {
|
||||
},
|
||||
},
|
||||
"accessTokens": []interface{}{"user", "tenant"},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func imSpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
func imSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileFlagRegistered(t *testing.T) {
|
||||
@@ -684,7 +685,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) {
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil)
|
||||
flag := cmd.Flags().Lookup("file")
|
||||
if flag != nil {
|
||||
t.Fatal("expected --file flag NOT to be registered for GET method")
|
||||
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
got := detectFileFields(meta.FromMap(tt.method))
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
@@ -771,7 +772,7 @@ func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
|
||||
12
go.mod
12
go.mod
@@ -27,6 +27,8 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/apache/arrow/go/v17 v17.0.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -42,13 +44,17 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -57,10 +63,16 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
)
|
||||
|
||||
32
go.sum
32
go.sum
@@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
|
||||
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -52,12 +54,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
@@ -74,11 +80,16 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -97,6 +108,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -133,14 +146,20 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -156,6 +175,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -169,10 +189,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
|
||||
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
396
internal/apicatalog/catalog.go
Normal file
396
internal/apicatalog/catalog.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package apicatalog is the single navigation Module over the API metadata. It
|
||||
// owns every "which services/resources/methods exist and how does a path
|
||||
// resolve" question that was previously duplicated across cmd/schema,
|
||||
// cmd/service, internal/schema and internal/registry. It depends only on
|
||||
// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog),
|
||||
// so apicatalog never imports registry.
|
||||
package apicatalog
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// Source records whether a catalog includes the remote overlay. It is carried
|
||||
// so callers (and tests) can assert determinism instead of guessing.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic
|
||||
SourceRuntime Source = "runtime" // embedded + remote overlay
|
||||
)
|
||||
|
||||
// MethodFilter optionally drops methods (e.g. by identity in strict mode).
|
||||
// A nil filter includes everything.
|
||||
type MethodFilter func(meta.Method) bool
|
||||
|
||||
// Catalog is a navigation view over services with a name index. It owns its
|
||||
// ordering — New sorts by name — so WalkMethods/Resolve/Complete are
|
||||
// deterministic regardless of how the source adapter ordered its input.
|
||||
type Catalog struct {
|
||||
source Source
|
||||
services []meta.Service
|
||||
byName map[string]meta.Service
|
||||
}
|
||||
|
||||
// New builds a Catalog over the given services, owning its navigation order:
|
||||
// the slice is copied and sorted by name so callers may pass any order and the
|
||||
// ordering contract is not delegated to the adapter. The copy is shallow —
|
||||
// meta.Service values share their Resources maps, which are treated as
|
||||
// read-only.
|
||||
func New(source Source, services []meta.Service) Catalog {
|
||||
sorted := append([]meta.Service(nil), services...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
byName := make(map[string]meta.Service, len(sorted))
|
||||
for _, s := range sorted {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
return Catalog{source: source, services: sorted, byName: byName}
|
||||
}
|
||||
|
||||
// Source reports embedded vs runtime.
|
||||
func (c Catalog) Source() Source { return c.source }
|
||||
|
||||
// Services returns the services in name order. Treat the result as read-only:
|
||||
// it is the Catalog's own ordered slice and its element Resources maps are
|
||||
// shared.
|
||||
func (c Catalog) Services() []meta.Service { return c.services }
|
||||
|
||||
// Service looks up one service by name.
|
||||
func (c Catalog) Service(name string) (meta.Service, bool) {
|
||||
s, ok := c.byName[name]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Resolve maps a path (already split into segments) to a Target. An empty path
|
||||
// is TargetAll. Failures return a *ResolveError carrying the available
|
||||
// candidates so the command layer can render a hint.
|
||||
func (c Catalog) Resolve(parts []string) (Target, error) {
|
||||
if len(parts) == 0 {
|
||||
return Target{Kind: TargetAll}, nil
|
||||
}
|
||||
svc, ok := c.byName[parts[0]]
|
||||
if !ok {
|
||||
return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()}
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return Target{Kind: TargetService, Service: svc}, nil
|
||||
}
|
||||
res, path, remaining, ok := findResource(svc, parts[1:])
|
||||
if !ok {
|
||||
return Target{}, &ResolveError{
|
||||
Kind: ErrResource,
|
||||
Subject: svc.Name + "." + strings.Join(parts[1:], "."),
|
||||
Candidates: resourceNames(svc),
|
||||
}
|
||||
}
|
||||
resPath := strings.Join(path, ".")
|
||||
if len(remaining) == 0 {
|
||||
return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
m, ok := res.Method(methodName)
|
||||
if !ok {
|
||||
return Target{}, &ResolveError{
|
||||
Kind: ErrMethod,
|
||||
Subject: svc.Name + "." + resPath + "." + methodName,
|
||||
Candidates: methodNames(res),
|
||||
}
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but trailing segments don't resolve — reject so a typo
|
||||
// doesn't silently return this method's schema.
|
||||
return Target{}, &ResolveError{
|
||||
Kind: ErrPath,
|
||||
Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."),
|
||||
Method: methodName,
|
||||
Trailing: strings.Join(remaining[1:], "."),
|
||||
}
|
||||
}
|
||||
return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil
|
||||
}
|
||||
|
||||
// MethodRefs returns the method refs selected by a resolved Target, filtered:
|
||||
// TargetAll -> every method, TargetService / TargetResource -> that subtree,
|
||||
// TargetMethod -> the single method if it passes the filter (else empty). It
|
||||
// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a
|
||||
// Target to refs in one call instead of re-deciding the walker per Kind.
|
||||
func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef {
|
||||
switch target.Kind {
|
||||
case TargetService:
|
||||
return ServiceMethods(target.Service, filter)
|
||||
case TargetResource:
|
||||
return ResourceMethods(*target.Resource, filter)
|
||||
case TargetMethod:
|
||||
if filter != nil && !filter(target.Method.Method) {
|
||||
return nil
|
||||
}
|
||||
return []MethodRef{*target.Method}
|
||||
case TargetAll:
|
||||
return c.WalkMethods(filter)
|
||||
default:
|
||||
// Unknown / zero-value Kind: return nothing rather than silently
|
||||
// dumping every method (the safe direction for an invalid Target).
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WalkMethods returns one MethodRef per method across all services (optionally
|
||||
// filtered), recursing nested resources, in a deterministic order: services by
|
||||
// name, resources by name, methods by name.
|
||||
func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef {
|
||||
var out []MethodRef
|
||||
for _, svc := range c.services {
|
||||
out = append(out, ServiceMethods(svc, filter)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ServiceMethods returns the method refs of one service (filtered), recursing
|
||||
// nested resources, in deterministic resource/method name order.
|
||||
func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef {
|
||||
var out []MethodRef
|
||||
walkResources(svc, svc.ResourceList(), nil, filter, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ResourceMethods returns the method refs under one resource (filtered), using
|
||||
// the resource's resolved path as the base and recursing nested resources.
|
||||
func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef {
|
||||
var out []MethodRef
|
||||
for _, m := range r.Resource.MethodList() {
|
||||
if filter == nil || filter(m) {
|
||||
out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m})
|
||||
}
|
||||
}
|
||||
walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) {
|
||||
for _, res := range resources {
|
||||
path := append(append([]string(nil), parentPath...), res.Name)
|
||||
for _, m := range res.MethodList() {
|
||||
if filter == nil || filter(m) {
|
||||
*out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m})
|
||||
}
|
||||
}
|
||||
walkResources(svc, res.SubResources(), path, filter, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Complete returns shell-completion candidates for the schema path argument,
|
||||
// supporting both the legacy single dotted arg ("im.reac") and the
|
||||
// space-separated form ("im reactions"). noSpace mirrors cobra's
|
||||
// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for
|
||||
// the next segment). Filtering uses the caller's MethodFilter so strict-mode
|
||||
// unavailable methods are hidden.
|
||||
func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) {
|
||||
// Case 1: legacy single dotted arg — no resolved args yet.
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
for _, name := range c.serviceNames() {
|
||||
if strings.HasPrefix(name, toComplete) {
|
||||
completions = append(completions, name+".")
|
||||
}
|
||||
}
|
||||
return completions, true
|
||||
}
|
||||
svc, ok := c.byName[parts[0]]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, comp := range completions {
|
||||
if !strings.HasSuffix(comp, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, allTrailingDot
|
||||
}
|
||||
|
||||
// Case 2: space-separated form — args holds resolved segments.
|
||||
svc, ok := c.byName[args[0]]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
resource, _, _, ok := findResource(svc, args[1:])
|
||||
if !ok {
|
||||
// No resource matched yet — suggest top-level resources reachable in the
|
||||
// current identity mode.
|
||||
return completeChildren(svc.ResourceList(), nil, toComplete, filter), false
|
||||
}
|
||||
// Positioned in a resource — offer its methods and its sub-resources, so the
|
||||
// next segment can drill deeper, symmetric to findResource's descent.
|
||||
return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false
|
||||
}
|
||||
|
||||
// completeDotted suggests dotted completions for the text after the service
|
||||
// segment. It descends fully-typed "resource." segments (longest match per
|
||||
// level, so flat dotted keys like "chat.members" and genuinely nested resources
|
||||
// both resolve), then offers the reachable sub-resources (as "…name.") and the
|
||||
// methods (as "…name") of the level it lands in whose names extend the trailing
|
||||
// partial token. This descent is symmetric to findResource, so completion can
|
||||
// reach every method Resolve can.
|
||||
func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string {
|
||||
subs := svc.ResourceList()
|
||||
base := svc.Name
|
||||
rest := afterService
|
||||
var here *meta.Resource // resource we're positioned in; nil at the service root
|
||||
for {
|
||||
matched, n, ok := longestResourceFollowedByDot(subs, rest)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
base += "." + matched.Name
|
||||
rest = rest[n:]
|
||||
r := matched
|
||||
here = &r
|
||||
subs = matched.SubResources()
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, sub := range subs {
|
||||
if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) {
|
||||
out = append(out, base+"."+sub.Name+".")
|
||||
}
|
||||
}
|
||||
if here != nil {
|
||||
for _, m := range here.MethodList() {
|
||||
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) {
|
||||
out = append(out, base+"."+m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// completeChildren returns the sorted next-segment candidates at one level: the
|
||||
// (filtered) methods and the reachable sub-resources whose names extend prefix.
|
||||
// Methods are terminal; sub-resources are bare names the caller drills into on
|
||||
// the next segment.
|
||||
func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string {
|
||||
var out []string
|
||||
for _, m := range methods {
|
||||
if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
}
|
||||
for _, sub := range subResources {
|
||||
if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) {
|
||||
out = append(out, sub.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// longestResourceFollowedByDot finds the longest resource in resources whose
|
||||
// name is a fully-typed segment of text (text begins with "name."), returning
|
||||
// it, the byte length consumed (incl. the dot), and whether one matched.
|
||||
func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) {
|
||||
best := meta.Resource{}
|
||||
bestLen := -1
|
||||
for _, r := range resources {
|
||||
if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") {
|
||||
best = r
|
||||
bestLen = len(r.Name)
|
||||
}
|
||||
}
|
||||
if bestLen < 0 {
|
||||
return meta.Resource{}, 0, false
|
||||
}
|
||||
return best, len(best.Name) + 1, true
|
||||
}
|
||||
|
||||
// findResource resolves a resource path against a service, descending nested
|
||||
// resources. At each level it consumes the longest leading run of parts that
|
||||
// names a resource at that level, so both flat dotted keys ("chat.members")
|
||||
// and genuinely nested resources ("spaces" > "items") resolve. This descent is
|
||||
// symmetric to walkResources, which guarantees every path WalkMethods emits
|
||||
// resolves back (the round-trip contract). Returns the deepest matched resource
|
||||
// (Name injected), its path segments, the unconsumed remainder, and whether
|
||||
// anything matched.
|
||||
//
|
||||
// Descent is greedy and resource-first: the one ambiguous case is a resource
|
||||
// that has BOTH a method and a sub-resource of the same name — the sub-resource
|
||||
// wins and shadows the method, so Resolve can never reach that method. Real
|
||||
// metadata never collides the two, so this is theoretical.
|
||||
func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) {
|
||||
level := svc.Resources
|
||||
remaining = parts
|
||||
for len(remaining) > 0 {
|
||||
matched, name, n := longestResourcePrefix(level, remaining)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
matched.Name = name
|
||||
res = matched
|
||||
path = append(path, name)
|
||||
remaining = remaining[n:]
|
||||
level = matched.Resources
|
||||
ok = true
|
||||
}
|
||||
return res, path, remaining, ok
|
||||
}
|
||||
|
||||
// longestResourcePrefix finds the longest leading run of segs (joined by ".")
|
||||
// that names a resource in level, returning the resource, its dotted name, and
|
||||
// the number of segments consumed (0 if none match). Longest-first lets a flat
|
||||
// dotted key win over its single leading segment when present.
|
||||
func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) {
|
||||
for i := len(segs); i >= 1; i-- {
|
||||
name := strings.Join(segs[:i], ".")
|
||||
if r, ok := level[name]; ok {
|
||||
return r, name, i
|
||||
}
|
||||
}
|
||||
return meta.Resource{}, "", 0
|
||||
}
|
||||
|
||||
// resourceReachable reports whether a resource exposes a method reachable under
|
||||
// the filter — directly or in any nested sub-resource (a nil filter accepts any
|
||||
// method). A resource whose methods are all filtered out but which contains a
|
||||
// reachable nested method is still offerable, so completion can drill into it.
|
||||
func resourceReachable(res meta.Resource, filter MethodFilter) bool {
|
||||
for _, m := range res.MethodList() {
|
||||
if filter == nil || filter(m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, sub := range res.SubResources() {
|
||||
if resourceReachable(sub, filter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Catalog) serviceNames() []string {
|
||||
names := make([]string, len(c.services))
|
||||
for i, s := range c.services {
|
||||
names[i] = s.Name
|
||||
}
|
||||
return names // c.services is already name-sorted
|
||||
}
|
||||
|
||||
func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) }
|
||||
func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) }
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
340
internal/apicatalog/catalog_test.go
Normal file
340
internal/apicatalog/catalog_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// testCatalog builds a small embedded catalog: services drive (no resources)
|
||||
// and im with a dotted resource (chat.members), a multi-method resource
|
||||
// (reactions, where list is user-only), and images.
|
||||
func testCatalog() apicatalog.Catalog {
|
||||
im := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "im",
|
||||
"resources": map[string]interface{}{
|
||||
"chat.members": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
},
|
||||
"reactions": map[string]interface{}{
|
||||
"methods": map[string]interface{}{
|
||||
"create": map[string]interface{}{},
|
||||
"list": map[string]interface{}{"accessTokens": []interface{}{"user"}},
|
||||
},
|
||||
},
|
||||
"images": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
},
|
||||
},
|
||||
})
|
||||
drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"})
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted
|
||||
}
|
||||
|
||||
func TestNew_PreservesOrderAndLookup(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if c.Source() != apicatalog.SourceEmbedded {
|
||||
t.Fatalf("source = %q", c.Source())
|
||||
}
|
||||
names := []string{}
|
||||
for _, s := range c.Services() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"drive", "im"}) {
|
||||
t.Errorf("Services order = %v, want [drive im]", names)
|
||||
}
|
||||
if _, ok := c.Service("im"); !ok {
|
||||
t.Error("Service(im) not found")
|
||||
}
|
||||
if _, ok := c.Service("nope"); ok {
|
||||
t.Error("Service(nope) should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts
|
||||
// arbitrary input by service name and shallow-copies the slice so later caller
|
||||
// mutation can't reorder the Catalog.
|
||||
func TestNew_SortsAndIsolatesInput(t *testing.T) {
|
||||
in := []meta.Service{
|
||||
meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}),
|
||||
meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}),
|
||||
}
|
||||
c := apicatalog.New(apicatalog.SourceEmbedded, in)
|
||||
|
||||
names := func() []string {
|
||||
var out []string
|
||||
for _, s := range c.Services() {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
|
||||
t.Errorf("New did not sort unsorted input: %v", got)
|
||||
}
|
||||
|
||||
// Mutating the caller's slice afterward must not reorder the Catalog.
|
||||
in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"})
|
||||
if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
|
||||
t.Errorf("Catalog order changed after caller mutated its input slice: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkMethods_AllAndFiltered(t *testing.T) {
|
||||
c := testCatalog()
|
||||
|
||||
all := c.WalkMethods(nil)
|
||||
got := map[string]bool{}
|
||||
for _, r := range all {
|
||||
got[r.SchemaPath()] = true
|
||||
}
|
||||
want := []string{
|
||||
"im.chat.members.create",
|
||||
"im.images.create",
|
||||
"im.reactions.create",
|
||||
"im.reactions.list",
|
||||
}
|
||||
if len(all) != len(want) {
|
||||
t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got)
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Errorf("WalkMethods(nil) missing %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic order: services by name, resources by name, methods by name.
|
||||
var order []string
|
||||
for _, r := range all {
|
||||
order = append(order, r.SchemaPath())
|
||||
}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Errorf("WalkMethods order = %v, want %v", order, want)
|
||||
}
|
||||
|
||||
// Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods
|
||||
// with no accessTokens are permissive and stay.
|
||||
botOnly := func(m meta.Method) bool {
|
||||
if m.AccessTokens == nil {
|
||||
return true
|
||||
}
|
||||
for _, tok := range m.AccessTokens {
|
||||
if tok == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
filtered := c.WalkMethods(botOnly)
|
||||
for _, r := range filtered {
|
||||
if r.SchemaPath() == "im.reactions.list" {
|
||||
t.Error("filtered walk should drop user-only im.reactions.list")
|
||||
}
|
||||
}
|
||||
if len(filtered) != len(all)-1 {
|
||||
t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) {
|
||||
c := testCatalog()
|
||||
target, err := c.Resolve([]string{"im", "chat.members", "create"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if target.Kind != apicatalog.TargetMethod {
|
||||
t.Fatalf("kind = %v", target.Kind)
|
||||
}
|
||||
m := target.Method
|
||||
if m.SchemaPath() != "im.chat.members.create" {
|
||||
t.Errorf("SchemaPath = %q", m.SchemaPath())
|
||||
}
|
||||
if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) {
|
||||
t.Errorf("CommandPath = %v", m.CommandPath())
|
||||
}
|
||||
if m.ResourceName() != "chat.members" {
|
||||
t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName())
|
||||
}
|
||||
if m.Method.Name != "create" {
|
||||
t.Errorf("Method.Name not injected: %q", m.Method.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) {
|
||||
c := testCatalog()
|
||||
// schema.ParsePath splits both "im.chat.members.create" and
|
||||
// "im chat.members create" into segments; findResource's longest-prefix
|
||||
// must resolve the dotted resource either way.
|
||||
a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split
|
||||
b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment
|
||||
if errA != nil || errB != nil {
|
||||
t.Fatalf("errA=%v errB=%v", errA, errB)
|
||||
}
|
||||
if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" {
|
||||
t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Targets(t *testing.T) {
|
||||
c := testCatalog()
|
||||
if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll {
|
||||
t.Errorf("empty -> %v, want all", tg.Kind)
|
||||
}
|
||||
if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" {
|
||||
t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name)
|
||||
}
|
||||
if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" {
|
||||
t.Errorf("[im reactions] -> %v", tg.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Errors(t *testing.T) {
|
||||
c := testCatalog()
|
||||
cases := []struct {
|
||||
parts []string
|
||||
kind apicatalog.ResolveErrorKind
|
||||
}{
|
||||
{[]string{"nope"}, apicatalog.ErrService},
|
||||
{[]string{"im", "nope"}, apicatalog.ErrResource},
|
||||
{[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod},
|
||||
{[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := c.Resolve(tc.parts)
|
||||
var re *apicatalog.ResolveError
|
||||
if !errors.As(err, &re) {
|
||||
t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err)
|
||||
continue
|
||||
}
|
||||
if re.Kind != tc.kind {
|
||||
t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind)
|
||||
}
|
||||
if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 {
|
||||
t.Errorf("%v -> expected candidates", tc.parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a
|
||||
// flat dotted resource (chat.members), so the round-trip contract is exercised
|
||||
// for real nesting — not just flat dotted keys.
|
||||
func nestedCatalog() apicatalog.Catalog {
|
||||
im := meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "im",
|
||||
"resources": map[string]interface{}{
|
||||
"chat.members": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
},
|
||||
"spaces": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"create": map[string]interface{}{}},
|
||||
"resources": map[string]interface{}{
|
||||
"items": map[string]interface{}{
|
||||
"methods": map[string]interface{}{"get": map[string]interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im})
|
||||
}
|
||||
|
||||
// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method
|
||||
// WalkMethods emits must Resolve back to the same method — both from its dotted
|
||||
// SchemaPath (fully split) and from its CommandPath (resource as one segment).
|
||||
// This pins findResource's nested-resource descent symmetric to walkResources,
|
||||
// so "traversable" implies "resolvable".
|
||||
func TestResolve_WalkMethodsRoundTrip(t *testing.T) {
|
||||
for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} {
|
||||
for _, ref := range c.WalkMethods(nil) {
|
||||
want := ref.SchemaPath()
|
||||
for _, parts := range [][]string{
|
||||
strings.Split(want, "."), // fully-split dotted form
|
||||
ref.CommandPath(), // command form (resource stays one segment)
|
||||
} {
|
||||
tg, err := c.Resolve(parts)
|
||||
if err != nil {
|
||||
t.Errorf("round-trip %v: %v", parts, err)
|
||||
continue
|
||||
}
|
||||
if tg.Kind != apicatalog.TargetMethod {
|
||||
t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind)
|
||||
continue
|
||||
}
|
||||
if tg.Method.SchemaPath() != want {
|
||||
t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplete_Nested pins completion closure for genuinely nested resources:
|
||||
// both the dotted and space forms must reach a nested method, symmetric to
|
||||
// Resolve (findResource descends, so completion must too).
|
||||
func TestComplete_Nested(t *testing.T) {
|
||||
c := nestedCatalog()
|
||||
|
||||
// dotted: under a resource, offer its methods AND its sub-resources
|
||||
if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns {
|
||||
t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns)
|
||||
}
|
||||
// dotted: drill into the nested sub-resource's method
|
||||
if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns {
|
||||
t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns)
|
||||
}
|
||||
// dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type)
|
||||
if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns {
|
||||
t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns)
|
||||
}
|
||||
// space form: under a resource, offer methods AND sub-resources
|
||||
if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) {
|
||||
t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps)
|
||||
}
|
||||
// space form: drill into the nested sub-resource's methods
|
||||
if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) {
|
||||
t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplete(t *testing.T) {
|
||||
c := testCatalog()
|
||||
|
||||
// dotted: service prefix -> "im." (NoSpace)
|
||||
if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns {
|
||||
t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns)
|
||||
}
|
||||
// dotted: resource prefix -> "im.reactions." (NoSpace)
|
||||
if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) {
|
||||
t.Errorf("Complete([], im.rea) = %v", comps)
|
||||
}
|
||||
// space form: resource candidates under im (deterministic order)
|
||||
comps, ns := c.Complete([]string{"im"}, "", nil)
|
||||
if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns {
|
||||
t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns)
|
||||
}
|
||||
// space form: method candidates under reactions
|
||||
if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) {
|
||||
t.Errorf("Complete([im reactions], '') = %v", comps)
|
||||
}
|
||||
// filter applied: bot-only hides user-only list
|
||||
botOnly := func(m meta.Method) bool {
|
||||
if m.AccessTokens == nil {
|
||||
return true
|
||||
}
|
||||
for _, tok := range m.AccessTokens {
|
||||
if tok == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) {
|
||||
t.Errorf("Complete with bot filter = %v, want [create]", comps)
|
||||
}
|
||||
}
|
||||
75
internal/apicatalog/methodref.go
Normal file
75
internal/apicatalog/methodref.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// TargetKind classifies what a schema/command path resolves to.
|
||||
type TargetKind string
|
||||
|
||||
const (
|
||||
TargetAll TargetKind = "all" // empty path: every method
|
||||
TargetService TargetKind = "service" // <service>
|
||||
TargetResource TargetKind = "resource" // <service> <resource...>
|
||||
TargetMethod TargetKind = "method" // <service> <resource...> <method>
|
||||
)
|
||||
|
||||
// Target is the result of Catalog.Resolve. Resource and Method are populated
|
||||
// only for TargetResource and TargetMethod respectively.
|
||||
type Target struct {
|
||||
Kind TargetKind
|
||||
Service meta.Service
|
||||
Resource *ResourceRef
|
||||
Method *MethodRef
|
||||
}
|
||||
|
||||
// ResourceRef identifies one resource within a service. Path holds the resource
|
||||
// path segments (one element for the common flat dotted resource like
|
||||
// "chat.members"; multiple for genuinely nested resources).
|
||||
type ResourceRef struct {
|
||||
Service meta.Service
|
||||
Resource meta.Resource
|
||||
Path []string
|
||||
}
|
||||
|
||||
// MethodRef identifies one method, carrying the full navigation context so the
|
||||
// command path and schema path can be derived without re-walking the catalog.
|
||||
type MethodRef struct {
|
||||
Service meta.Service
|
||||
Resource meta.Resource
|
||||
ResourcePath []string
|
||||
Method meta.Method
|
||||
}
|
||||
|
||||
// SchemaPath is the dotted "service.resource" identifier.
|
||||
func (r ResourceRef) SchemaPath() string {
|
||||
return r.Service.Name + "." + strings.Join(r.Path, ".")
|
||||
}
|
||||
|
||||
// ServiceName returns the owning service name.
|
||||
func (r MethodRef) ServiceName() string { return r.Service.Name }
|
||||
|
||||
// ResourceName is the dotted resource path, e.g. "chat.members".
|
||||
func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") }
|
||||
|
||||
// MethodName returns the method's own name.
|
||||
func (r MethodRef) MethodName() string { return r.Method.Name }
|
||||
|
||||
// SchemaPath is the dotted "service.resource.method" identifier, e.g.
|
||||
// "im.chat.members.create".
|
||||
func (r MethodRef) SchemaPath() string {
|
||||
return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name
|
||||
}
|
||||
|
||||
// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"].
|
||||
func (r MethodRef) CommandPath() []string {
|
||||
out := make([]string, 0, len(r.ResourcePath)+2)
|
||||
out = append(out, r.Service.Name)
|
||||
out = append(out, r.ResourcePath...)
|
||||
return append(out, r.Method.Name)
|
||||
}
|
||||
31
internal/apicatalog/path.go
Normal file
31
internal/apicatalog/path.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParsePath normalizes positional command arguments into the path segments
|
||||
// Resolve consumes. It accepts two equivalent forms:
|
||||
//
|
||||
// im.messages.reply -> single arg, split on "."
|
||||
// im messages reply -> multiple args, used as-is
|
||||
//
|
||||
// "im chat.members bots" as a single quoted arg is NOT supported; quote
|
||||
// arguments individually if your shell needs it. A resource keeps its internal
|
||||
// dots when passed as one segment (e.g. "chat.members"); findResource's
|
||||
// longest-prefix descent resolves both the split and the one-segment forms to
|
||||
// the same target. Returns nil for zero args (bare invocation -> TargetAll).
|
||||
func ParsePath(args []string) []string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if strings.Contains(args[0], ".") {
|
||||
return strings.Split(args[0], ".")
|
||||
}
|
||||
return []string{args[0]}
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
package apicatalog_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
@@ -25,7 +27,7 @@ func TestParsePath(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParsePath(tt.args)
|
||||
got := apicatalog.ParsePath(tt.args)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
30
internal/apicatalog/resolveerror.go
Normal file
30
internal/apicatalog/resolveerror.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apicatalog
|
||||
|
||||
// ResolveErrorKind classifies a Resolve failure so the command layer can render
|
||||
// the right hint without re-deriving what was being looked up.
|
||||
type ResolveErrorKind string
|
||||
|
||||
const (
|
||||
ErrService ResolveErrorKind = "service"
|
||||
ErrResource ResolveErrorKind = "resource"
|
||||
ErrMethod ResolveErrorKind = "method"
|
||||
ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve
|
||||
)
|
||||
|
||||
// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that
|
||||
// failed to resolve; Candidates lists the available names at that level (nil for
|
||||
// ErrPath, which instead carries the matched Method and the unresolved Trailing).
|
||||
type ResolveError struct {
|
||||
Kind ResolveErrorKind
|
||||
Subject string
|
||||
Candidates []string
|
||||
Method string
|
||||
Trailing string
|
||||
}
|
||||
|
||||
func (e *ResolveError) Error() string {
|
||||
return "unknown " + string(e.Kind) + ": " + e.Subject
|
||||
}
|
||||
@@ -100,9 +100,19 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
|
||||
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
|
||||
// needed because the real function is guarded by an internal sync.Once, so
|
||||
// calling it directly would only fire on the first test (see
|
||||
// factory_proxy_warn_test.go). The terminal check is the IOStreams
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
@@ -129,7 +139,9 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
|
||||
85
internal/cmdutil/factory_proxy_warn_test.go
Normal file
85
internal/cmdutil/factory_proxy_warn_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// installProxyWarnSpy replaces warnIfProxied with a counter for one test and
|
||||
// restores it on cleanup. Returns a pointer to the call count so the caller can
|
||||
// assert how many times the warning fired. The terminal state is controlled via
|
||||
// the IOStreams.StderrIsTerminal field, not a seam.
|
||||
func installProxyWarnSpy(t *testing.T) *int {
|
||||
t.Helper()
|
||||
prevWarn := warnIfProxied
|
||||
t.Cleanup(func() { warnIfProxied = prevWarn })
|
||||
calls := 0
|
||||
warnIfProxied = func(io.Writer) { calls++ }
|
||||
return &calls
|
||||
}
|
||||
|
||||
var proxyWarnGateCases = []struct {
|
||||
name string
|
||||
terminal bool
|
||||
want int
|
||||
}{
|
||||
{"terminal stderr warns once", true, 1},
|
||||
{"non-terminal stderr stays silent", false, 0},
|
||||
}
|
||||
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
|
||||
if *calls != tc.want {
|
||||
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCachedLarkClientFunc_ProxyWarnGate verifies the lark-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal. The gate
|
||||
// runs after ResolveAccount, so an env-backed credential is wired up to let
|
||||
// account resolution succeed without network or config files.
|
||||
func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env-app")
|
||||
t.Setenv(envvars.CliAppSecret, "env-secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
// normalizeStreams copies the struct (out := *s), so the
|
||||
// StderrIsTerminal field survives into f.IOStreams.
|
||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
||||
if _, err := cachedLarkClientFunc(f)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
if *calls != tc.want {
|
||||
t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,23 +12,9 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// DetectFileFields returns field names with type "file" in the method's requestBody.
|
||||
func DetectFileFields(method map[string]interface{}) []string {
|
||||
rb, _ := method["requestBody"].(map[string]interface{})
|
||||
var fields []string
|
||||
for name, field := range rb {
|
||||
f, _ := field.(map[string]interface{})
|
||||
if registry.GetStrFromMap(f, "type") == "file" {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ParseFileFlag parses a --file flag value into its components.
|
||||
// The format is either "path" or "field=path". When no explicit "field="
|
||||
// prefix is present, defaultField is used as the field name.
|
||||
|
||||
@@ -10,22 +10,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"])
|
||||
// to CLI identity names (e.g. ["bot", "user"]).
|
||||
func AccessTokensToIdentities(tokens []interface{}) []string {
|
||||
var identities []string
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok {
|
||||
if ts == "tenant" {
|
||||
identities = append(identities, "bot")
|
||||
} else {
|
||||
identities = append(identities, ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// PrintIdentity outputs the current identity to stderr so callers (including AI agents)
|
||||
// can see which identity is being used for the API call.
|
||||
func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) {
|
||||
|
||||
@@ -11,54 +11,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestAccessTokensToIdentities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tokens []interface{}
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "tenant becomes bot",
|
||||
tokens: []interface{}{"tenant"},
|
||||
want: []string{"bot"},
|
||||
},
|
||||
{
|
||||
name: "user stays user",
|
||||
tokens: []interface{}{"user"},
|
||||
want: []string{"user"},
|
||||
},
|
||||
{
|
||||
name: "tenant and user",
|
||||
tokens: []interface{}{"tenant", "user"},
|
||||
want: []string{"bot", "user"},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
tokens: []interface{}{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "non-string values skipped",
|
||||
tokens: []interface{}{"tenant", 42, "user"},
|
||||
want: []string{"bot", "user"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := AccessTokensToIdentities(tt.tokens)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIdentity_BotExplicit(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
PrintIdentity(&buf, core.AsBot, nil, false)
|
||||
|
||||
@@ -18,17 +18,28 @@ type IOStreams struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
IsTerminal bool
|
||||
// StderrIsTerminal reports whether ErrOut is an interactive terminal.
|
||||
// Advisory warnings written to stderr (e.g. the proxy notice) gate on this
|
||||
// so they stay out of non-interactive output (pipes, CI, agent runs).
|
||||
// Computed once in NewIOStreams, mirroring IsTerminal; tests assign it
|
||||
// directly like cmd/config/bind_test.go does for IsTerminal.
|
||||
StderrIsTerminal bool
|
||||
}
|
||||
|
||||
// NewIOStreams builds an IOStreams from arbitrary readers/writers.
|
||||
// IsTerminal is derived from in's underlying *os.File, if any; non-file
|
||||
// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false.
|
||||
// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying
|
||||
// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield
|
||||
// false.
|
||||
func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
isTerminal := false
|
||||
if f, ok := in.(*os.File); ok {
|
||||
isTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal}
|
||||
stderrIsTerminal := false
|
||||
if f, ok := errOut.(*os.File); ok {
|
||||
stderrIsTerminal = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
|
||||
@@ -34,7 +34,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
|
||||
// ParseJSONMap parses a JSON string into a map. Returns an empty (never nil) map
|
||||
// for empty input or the JSON literal null, so callers can always overlay onto
|
||||
// the result without a nil-map panic.
|
||||
// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput.
|
||||
func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) {
|
||||
resolved, err := ResolveInput(input, stdin, fileIO)
|
||||
@@ -48,5 +50,10 @@ func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (m
|
||||
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
|
||||
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
|
||||
}
|
||||
if result == nil {
|
||||
// `null` unmarshals into a nil map without error; normalize it so the
|
||||
// returned map is always writable, matching the empty-input case.
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func TestParseJSONMap(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty input", "", "--params", 0, false},
|
||||
{"json null", "null", "--params", 0, false},
|
||||
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
|
||||
{"invalid json", `{bad}`, "--params", 0, true},
|
||||
{"json array", `[1,2]`, "--data", 0, true},
|
||||
@@ -61,6 +62,12 @@ func TestParseJSONMap(t *testing.T) {
|
||||
if !tt.wantErr && len(got) != tt.wantLen {
|
||||
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
|
||||
}
|
||||
// A successful parse must yield a non-nil, writable map: callers
|
||||
// overlay onto it (params[k]=v), so `null` — which unmarshals to a
|
||||
// nil map without error — must normalize to {} like empty input.
|
||||
if !tt.wantErr && got == nil {
|
||||
t.Error("ParseJSONMap() = nil map on success, want non-nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// Risk level constants — the three-tier convention used across the CLI.
|
||||
// Use these in place of string literals so the typo radius is one place,
|
||||
// not every call site.
|
||||
// Risk level constants — aliases of the canonical core.Risk* values, re-exported
|
||||
// here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers
|
||||
// from one package. core is the single source of truth.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
RiskRead = core.RiskRead
|
||||
RiskWrite = core.RiskWrite
|
||||
RiskHighRiskWrite = core.RiskHighRiskWrite
|
||||
)
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
|
||||
15
internal/core/risk.go
Normal file
15
internal/core/risk.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
// Risk levels — the three-tier convention used across the CLI. They live here,
|
||||
// at the leaf, so the envelope renderer (internal/schema) and the command
|
||||
// toolkit (internal/cmdutil) share one vocabulary without a renderer depending
|
||||
// on command utilities. Framework confirmation gating acts only on
|
||||
// RiskHighRiskWrite.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
44
internal/meta/affordance.go
Normal file
44
internal/meta/affordance.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Affordance is the hand-authored usage guidance overlaid on a method: when to
|
||||
// use it, when not to, prerequisites, few-shot examples, and related methods.
|
||||
// It is the single typed model of the affordance shape; the envelope renderer
|
||||
// and the command help both parse through ParsedAffordance so the vocabulary
|
||||
// is defined once. The JSON tags double as the envelope's wire shape.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one few-shot example: a one-line description and a
|
||||
// ready-to-run command.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ParsedAffordance decodes the method's raw affordance overlay into the typed
|
||||
// Affordance. ok is false when the method carries no affordance, the JSON is
|
||||
// malformed, or every section is empty — so callers can treat "no guidance"
|
||||
// uniformly.
|
||||
func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if len(m.Affordance) == 0 {
|
||||
return Affordance{}, false
|
||||
}
|
||||
var a Affordance
|
||||
if json.Unmarshal(m.Affordance, &a) != nil {
|
||||
return Affordance{}, false
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
return Affordance{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
56
internal/meta/affordance_test.go
Normal file
56
internal/meta/affordance_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
// absent / empty / malformed all resolve to ok=false.
|
||||
t.Run("nil affordance", func(t *testing.T) {
|
||||
if _, ok := (Method{}).ParsedAffordance(); ok {
|
||||
t.Error("ParsedAffordance on a method without affordance ok=true, want false")
|
||||
}
|
||||
})
|
||||
|
||||
notOK := map[string]string{
|
||||
"empty payload": ``,
|
||||
"empty object": `{}`,
|
||||
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
|
||||
"malformed string": `"not an object"`,
|
||||
"malformed number": `42`,
|
||||
"nested type mismatch": `{"examples":"should be a list"}`,
|
||||
}
|
||||
for name, raw := range notOK {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance(); ok {
|
||||
t.Errorf("ParsedAffordance(%s) ok=true, want false", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Populated affordance parses with all fields.
|
||||
raw := `{
|
||||
"use_when": ["需要拿到当前用户的主日历 ID"],
|
||||
"do_not_use_when": ["已知具体 calendar_id"],
|
||||
"prerequisites": ["user 身份登录"],
|
||||
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
||||
"related": ["calendars.list"]
|
||||
}`
|
||||
a, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance()
|
||||
if !ok {
|
||||
t.Fatal("ParsedAffordance ok=false, want populated")
|
||||
}
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
94
internal/meta/identity.go
Normal file
94
internal/meta/identity.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import "sort"
|
||||
|
||||
// Token is the metadata accessTokens vocabulary: which token kind a method
|
||||
// accepts. It is a distinct type so the two directions of the token<->identity
|
||||
// mapping below cannot be swapped silently — a bare string compiles on either
|
||||
// side of a string/string signature, a Token does not. The CLI identity
|
||||
// vocabulary ("bot"/"user") already has a home in internal/core (core.Identity);
|
||||
// meta is a leaf and must not import core, so the identity side stays a plain
|
||||
// string here and is typed at the core boundary.
|
||||
type Token string
|
||||
|
||||
const (
|
||||
TokenTenant Token = "tenant" // bot calls use tenant_access_token
|
||||
TokenUser Token = "user"
|
||||
)
|
||||
|
||||
// IdentityForToken maps a metadata access token to the CLI identity (--as
|
||||
// value) that uses it: tenant -> "bot", user -> "user". ok is false for
|
||||
// unrecognized tokens. This is the single source of truth for the
|
||||
// token<->identity vocabulary; schema, registry and command code all go
|
||||
// through it instead of re-spelling the mapping.
|
||||
func IdentityForToken(token Token) (string, bool) {
|
||||
switch token {
|
||||
case TokenTenant:
|
||||
return "bot", true
|
||||
case TokenUser:
|
||||
return "user", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TokenForIdentity is the inverse of IdentityForToken: "bot" -> TokenTenant;
|
||||
// everything else (notably "user") maps to itself.
|
||||
func TokenForIdentity(identity string) Token {
|
||||
if identity == "bot" {
|
||||
return TokenTenant
|
||||
}
|
||||
return Token(identity)
|
||||
}
|
||||
|
||||
// RestrictsIdentity reports whether the method limits which identities may call
|
||||
// it: true exactly when it declares one or more accessTokens. nil OR an empty
|
||||
// slice means unrestricted (any identity). This is the single rule that both
|
||||
// the strict-mode predicate (SupportsToken) and command identity gates use, so
|
||||
// nil and [] never diverge across schema/scope and execution.
|
||||
func (m Method) RestrictsIdentity() bool {
|
||||
return len(m.AccessTokens) > 0
|
||||
}
|
||||
|
||||
// SupportsToken reports whether this method is reachable with the given access
|
||||
// token (see TokenForIdentity). An unrestricted method (RestrictsIdentity ==
|
||||
// false, i.e. nil or empty accessTokens) is reachable by any token. This is
|
||||
// the single source of truth for the predicate; registry scope policy and
|
||||
// command identity checks build on it.
|
||||
func (m Method) SupportsToken(token Token) bool {
|
||||
if !m.RestrictsIdentity() {
|
||||
return true
|
||||
}
|
||||
for _, t := range m.AccessTokens {
|
||||
if t == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Identities returns the CLI identities (--as values) that can call this
|
||||
// method, derived from its metadata accessTokens: tenant -> "bot", user
|
||||
// stays "user"; unrecognized tokens are dropped; the result is deduped and
|
||||
// name-sorted. The slice is always non-nil so callers rendering it (e.g. the
|
||||
// envelope's access_tokens) emit [] rather than null.
|
||||
//
|
||||
// An empty result does NOT imply unrestricted — use RestrictsIdentity() for
|
||||
// that. Identities() lists only CLI-known identities, so a method restricted
|
||||
// solely to unrecognized tokens returns empty yet RestrictsIdentity() is true.
|
||||
func (m Method) Identities() []string {
|
||||
seen := make(map[string]bool, len(m.AccessTokens))
|
||||
for _, t := range m.AccessTokens {
|
||||
if id, ok := IdentityForToken(t); ok {
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
85
internal/meta/identity_test.go
Normal file
85
internal/meta/identity_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIdentityTokenBijection(t *testing.T) {
|
||||
if got := TokenForIdentity("bot"); got != "tenant" {
|
||||
t.Errorf("TokenForIdentity(bot) = %q, want tenant", got)
|
||||
}
|
||||
if got := TokenForIdentity("user"); got != "user" {
|
||||
t.Errorf("TokenForIdentity(user) = %q, want user", got)
|
||||
}
|
||||
if id, ok := IdentityForToken("tenant"); id != "bot" || !ok {
|
||||
t.Errorf("IdentityForToken(tenant) = %q,%v want bot,true", id, ok)
|
||||
}
|
||||
if id, ok := IdentityForToken("user"); id != "user" || !ok {
|
||||
t.Errorf("IdentityForToken(user) = %q,%v want user,true", id, ok)
|
||||
}
|
||||
if _, ok := IdentityForToken("weird"); ok {
|
||||
t.Error("IdentityForToken(weird) ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethod_RestrictsIdentity(t *testing.T) {
|
||||
// nil and empty both mean "unrestricted"; only a populated list restricts.
|
||||
if (Method{}).RestrictsIdentity() {
|
||||
t.Error("nil accessTokens must be unrestricted")
|
||||
}
|
||||
if (Method{AccessTokens: []Token{}}).RestrictsIdentity() {
|
||||
t.Error("empty accessTokens must be unrestricted (same as nil)")
|
||||
}
|
||||
if !(Method{AccessTokens: []Token{"tenant"}}).RestrictsIdentity() {
|
||||
t.Error("populated accessTokens must restrict identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethod_SupportsToken(t *testing.T) {
|
||||
// unrestricted (nil OR empty) -> permissive for any token; the two must not
|
||||
// diverge, else strict/scope and the command gate disagree.
|
||||
for _, m := range []Method{{}, {AccessTokens: []Token{}}} {
|
||||
if !m.SupportsToken("tenant") || !m.SupportsToken("user") {
|
||||
t.Errorf("unrestricted method %#v should support any token", m.AccessTokens)
|
||||
}
|
||||
}
|
||||
// restricted: only the declared tokens are reachable
|
||||
m := Method{AccessTokens: []Token{"tenant"}}
|
||||
if !m.SupportsToken("tenant") {
|
||||
t.Error("tenant-declared method should support tenant")
|
||||
}
|
||||
if m.SupportsToken("user") {
|
||||
t.Error("tenant-only method must NOT support user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethod_Identities(t *testing.T) {
|
||||
// tenant->bot, user stays; deduped + name-sorted (so order-independent);
|
||||
// unrecognized dropped; absent tokens -> empty but NON-nil so the envelope
|
||||
// renders [] not null.
|
||||
tests := []struct {
|
||||
name string
|
||||
tokens []Token
|
||||
want []string
|
||||
}{
|
||||
{"tenant only", []Token{"tenant"}, []string{"bot"}},
|
||||
{"user only", []Token{"user"}, []string{"user"}},
|
||||
{"tenant then user", []Token{"tenant", "user"}, []string{"bot", "user"}},
|
||||
{"user then tenant", []Token{"user", "tenant"}, []string{"bot", "user"}},
|
||||
{"deduped", []Token{"tenant", "tenant", "user"}, []string{"bot", "user"}},
|
||||
{"empty", []Token{}, []string{}},
|
||||
{"nil", nil, []string{}},
|
||||
{"unknown skipped", []Token{"user", "admin"}, []string{"user"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := (Method{AccessTokens: tt.tokens}).Identities(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Identities(%v) = %#v, want %#v", tt.tokens, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
215
internal/meta/meta.go
Normal file
215
internal/meta/meta.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package meta is the typed model of the API metadata registry and the single
|
||||
// place that parses it. The metadata is a fixed, regular vocabulary, so a plain
|
||||
// typed json.Unmarshal replaces hand-rolled map[string]interface{} walking. Map
|
||||
// key order is not preserved (Go maps are unordered); callers that need a
|
||||
// deterministic sequence get fields/methods/resources sorted by name via the
|
||||
// list accessors below.
|
||||
package meta
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Option is one enum option of a field. Value is `any` (not string) so a
|
||||
// metadata value that arrives as a JSON number — rather than the usual quoted
|
||||
// string — coerces like Field.Enum / EnumOption.Value instead of failing the
|
||||
// whole registry unmarshal and blanking the entire catalog. coerceLiteral
|
||||
// normalizes it to the field's declared type.
|
||||
type Option struct {
|
||||
Value any `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Field is one parameter or body/response field. Name is the parent map key,
|
||||
// populated by the list accessors (not a JSON field). ref/annotations/enumName
|
||||
// exist in the metadata but are intentionally not modeled (unused downstream).
|
||||
type Field struct {
|
||||
Name string `json:"-"`
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"` // "path" | "query"; empty for body/response
|
||||
Required bool `json:"required"`
|
||||
Description string `json:"description"`
|
||||
Default any `json:"default"`
|
||||
Example any `json:"example"`
|
||||
Min string `json:"min"`
|
||||
Max string `json:"max"`
|
||||
Enum []any `json:"enum"`
|
||||
Options []Option `json:"options"`
|
||||
Properties map[string]Field `json:"properties"`
|
||||
}
|
||||
|
||||
// FlagName is the kebab-case CLI flag for this field (chat_id -> chat-id).
|
||||
func (f Field) FlagName() string { return strings.ReplaceAll(f.Name, "_", "-") }
|
||||
|
||||
// Children returns the field's nested properties sorted by name.
|
||||
func (f Field) Children() []Field { return fieldsOf(f.Properties, nil) }
|
||||
|
||||
// Method is one API operation. Name is the parent map key. Affordance is kept
|
||||
// raw so this package stays free of envelope concerns.
|
||||
type Method struct {
|
||||
Name string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
HTTPMethod string `json:"httpMethod"`
|
||||
Description string `json:"description"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"docUrl"`
|
||||
Danger bool `json:"danger"`
|
||||
Tips []string `json:"tips"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RequiredScopes []string `json:"requiredScopes"`
|
||||
AccessTokens []Token `json:"accessTokens"`
|
||||
Affordance json.RawMessage `json:"affordance"`
|
||||
Parameters map[string]Field `json:"parameters"`
|
||||
RequestBody map[string]Field `json:"requestBody"`
|
||||
ResponseBody map[string]Field `json:"responseBody"`
|
||||
}
|
||||
|
||||
// Params are the path/query parameters, sorted by name.
|
||||
func (m Method) Params() []Field {
|
||||
return fieldsOf(m.Parameters, func(f Field) bool {
|
||||
return f.Location == "path" || f.Location == "query"
|
||||
})
|
||||
}
|
||||
|
||||
// Data are the non-file request-body fields (--data JSON), sorted by name.
|
||||
func (m Method) Data() []Field {
|
||||
return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type != "file" })
|
||||
}
|
||||
|
||||
// Files are the file-typed request-body fields (--file uploads), sorted by name.
|
||||
func (m Method) Files() []Field {
|
||||
return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type == "file" })
|
||||
}
|
||||
|
||||
// Response are the response-body fields, sorted by name.
|
||||
func (m Method) Response() []Field { return fieldsOf(m.ResponseBody, nil) }
|
||||
|
||||
// fieldsOf materializes a name->field map into a name-injected slice, optionally
|
||||
// filtered, sorted by name for deterministic output.
|
||||
func fieldsOf(byName map[string]Field, keep func(Field) bool) []Field {
|
||||
out := make([]Field, 0, len(byName))
|
||||
for name, f := range byName {
|
||||
f.Name = name
|
||||
if keep == nil || keep(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Resource groups methods (and may nest sub-resources). Name is the parent key.
|
||||
type Resource struct {
|
||||
Name string `json:"-"`
|
||||
Methods map[string]Method `json:"methods"`
|
||||
Resources map[string]Resource `json:"resources"`
|
||||
}
|
||||
|
||||
// MethodList returns the resource's methods, name-injected and sorted by name.
|
||||
func (r Resource) MethodList() []Method {
|
||||
out := make([]Method, 0, len(r.Methods))
|
||||
for name, m := range r.Methods {
|
||||
m.Name = name
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Method looks up one method by name with Name injected, or false if absent.
|
||||
// Use this instead of indexing Methods directly so Name is never left empty.
|
||||
func (r Resource) Method(name string) (Method, bool) {
|
||||
m, ok := r.Methods[name]
|
||||
if !ok {
|
||||
return Method{}, false
|
||||
}
|
||||
m.Name = name
|
||||
return m, true
|
||||
}
|
||||
|
||||
// SubResources returns nested resources, name-injected and sorted by name.
|
||||
func (r Resource) SubResources() []Resource { return resourcesOf(r.Resources) }
|
||||
|
||||
// Service is one API service. Name is a real JSON field (services is an array).
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ServicePath string `json:"servicePath"`
|
||||
Resources map[string]Resource `json:"resources"`
|
||||
}
|
||||
|
||||
// ResourceList returns the service's top-level resources, name-injected and
|
||||
// sorted by name.
|
||||
func (s Service) ResourceList() []Resource { return resourcesOf(s.Resources) }
|
||||
|
||||
// Resource looks up one (possibly dotted) resource by name with Name injected,
|
||||
// or false if absent. Use this instead of indexing Resources directly.
|
||||
func (s Service) Resource(name string) (Resource, bool) {
|
||||
r, ok := s.Resources[name]
|
||||
if !ok {
|
||||
return Resource{}, false
|
||||
}
|
||||
r.Name = name
|
||||
return r, true
|
||||
}
|
||||
|
||||
func resourcesOf(byName map[string]Resource) []Resource {
|
||||
out := make([]Resource, 0, len(byName))
|
||||
for name, r := range byName {
|
||||
r.Name = name
|
||||
out = append(out, r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Registry is the top-level metadata document.
|
||||
type Registry struct {
|
||||
Services []Service `json:"services"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Parse decodes the metadata JSON into the typed Registry. Returns a zero
|
||||
// Registry for empty input.
|
||||
func Parse(data []byte) (Registry, error) {
|
||||
if len(data) == 0 {
|
||||
return Registry{}, nil
|
||||
}
|
||||
var reg Registry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
return Registry{}, err
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// FromMap decodes a single method spec from its map form into a typed Method.
|
||||
// Convenience constructor for building typed values from map literals (tests).
|
||||
func FromMap(method map[string]interface{}) Method {
|
||||
b, err := json.Marshal(method)
|
||||
if err != nil {
|
||||
return Method{}
|
||||
}
|
||||
var m Method
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
// ServiceFromMap decodes a service spec from its map form into a typed Service.
|
||||
// Convenience constructor for building typed values from map literals (tests).
|
||||
func ServiceFromMap(svc map[string]interface{}) Service {
|
||||
b, err := json.Marshal(svc)
|
||||
if err != nil {
|
||||
return Service{}
|
||||
}
|
||||
var s Service
|
||||
_ = json.Unmarshal(b, &s)
|
||||
return s
|
||||
}
|
||||
140
internal/meta/meta_test.go
Normal file
140
internal/meta/meta_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleJSON = `{
|
||||
"version": "1.0.0",
|
||||
"services": [
|
||||
{
|
||||
"name": "im",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
"resources": {
|
||||
"chat.members": {
|
||||
"methods": {
|
||||
"create": {
|
||||
"httpMethod": "POST",
|
||||
"risk": "high-risk-write",
|
||||
"parameters": {
|
||||
"member_id_type": {"type": "string", "location": "query", "options": [{"value": "open_id"}, {"value": "user_id"}]},
|
||||
"chat_id": {"type": "string", "location": "path", "required": true, "example": "oc_x"},
|
||||
"x_header": {"type": "string", "location": "header"}
|
||||
},
|
||||
"requestBody": {
|
||||
"id_list": {"type": "list", "required": true},
|
||||
"avatar": {"type": "file"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func loadSample(t *testing.T) (Resource, Method) {
|
||||
t.Helper()
|
||||
reg, err := Parse([]byte(sampleJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
res := reg.Services[0].ResourceList()
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("want 1 resource, got %d", len(res))
|
||||
}
|
||||
methods := res[0].MethodList()
|
||||
if len(methods) != 1 {
|
||||
t.Fatalf("want 1 method, got %d", len(methods))
|
||||
}
|
||||
return res[0], methods[0]
|
||||
}
|
||||
|
||||
func TestParse_TypedAndNameInjected(t *testing.T) {
|
||||
res, m := loadSample(t)
|
||||
if res.Name != "chat.members" {
|
||||
t.Errorf("resource name = %q, want chat.members", res.Name)
|
||||
}
|
||||
if m.Name != "create" || m.HTTPMethod != "POST" || m.Risk != "high-risk-write" {
|
||||
t.Errorf("method = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethod_AccessorsSortedByName(t *testing.T) {
|
||||
_, m := loadSample(t)
|
||||
|
||||
// Params: path/query only (header dropped), sorted by name.
|
||||
var params []string
|
||||
for _, f := range m.Params() {
|
||||
params = append(params, f.Name)
|
||||
}
|
||||
if want := []string{"chat_id", "member_id_type"}; !reflect.DeepEqual(params, want) {
|
||||
t.Errorf("Params() = %v, want %v (sorted, header dropped)", params, want)
|
||||
}
|
||||
|
||||
if d := m.Data(); len(d) != 1 || d[0].Name != "id_list" {
|
||||
t.Errorf("Data() = %+v, want [id_list]", d)
|
||||
}
|
||||
if f := m.Files(); len(f) != 1 || f[0].Name != "avatar" {
|
||||
t.Errorf("Files() = %+v, want [avatar]", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_FlagNameAndOptions(t *testing.T) {
|
||||
_, m := loadSample(t)
|
||||
by := make(map[string]Field)
|
||||
for _, f := range m.Params() {
|
||||
by[f.Name] = f
|
||||
}
|
||||
|
||||
if got := by["chat_id"].FlagName(); got != "chat-id" {
|
||||
t.Errorf("FlagName = %q, want chat-id", got)
|
||||
}
|
||||
if !by["chat_id"].Required || by["chat_id"].Example != "oc_x" {
|
||||
t.Errorf("chat_id required/example wrong: %+v", by["chat_id"])
|
||||
}
|
||||
opts := by["member_id_type"].Options
|
||||
if len(opts) != 2 || opts[0].Value != "open_id" || opts[1].Value != "user_id" {
|
||||
t.Errorf("member_id_type options = %+v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParse_TolerantOptionValue guards against whole-catalog blanking: a single
|
||||
// options[].value that arrives as a JSON number (not the usual quoted string)
|
||||
// must NOT fail the entire registry unmarshal. Option.Value is `any`, so it
|
||||
// parses and coerces like Enum instead of returning an empty Registry.
|
||||
func TestParse_TolerantOptionValue(t *testing.T) {
|
||||
data := []byte(`{"services":[{"name":"im","servicePath":"/x","resources":{
|
||||
"chat":{"methods":{"create":{"parameters":{
|
||||
"flag":{"type":"integer","location":"query","options":[{"value":0,"description":"off"},{"value":1,"description":"on"}]}
|
||||
}}}}}}]}`)
|
||||
reg, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on numeric option value (would blank the catalog): %v", err)
|
||||
}
|
||||
if len(reg.Services) != 1 {
|
||||
t.Fatalf("expected 1 service, got %d (catalog blanked)", len(reg.Services))
|
||||
}
|
||||
// The numeric option coerces into the typed enum as sorted int64 (not
|
||||
// float64): the integer field's canonical type drives normalization.
|
||||
m, _ := reg.Services[0].Resource("chat")
|
||||
method, _ := m.Method("create")
|
||||
by := map[string]Field{}
|
||||
for _, f := range method.Params() {
|
||||
by[f.Name] = f
|
||||
}
|
||||
if got := by["flag"].EnumValues(); !reflect.DeepEqual(got, []any{int64(0), int64(1)}) {
|
||||
t.Errorf("numeric-valued enum did not coerce to sorted int64: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_Empty(t *testing.T) {
|
||||
reg, err := Parse(nil)
|
||||
if err != nil || len(reg.Services) != 0 {
|
||||
t.Fatalf("Parse(nil) = %+v, %v", reg, err)
|
||||
}
|
||||
}
|
||||
202
internal/meta/normalize.go
Normal file
202
internal/meta/normalize.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CanonicalType maps meta_data's non-standard type names to the standard
|
||||
// JSON-Schema/type vocabulary used downstream (envelope render, flag kinds):
|
||||
// "file" -> "string", "list" -> "array"; other types pass through unchanged.
|
||||
func (f Field) CanonicalType() string {
|
||||
switch f.Type {
|
||||
case "file":
|
||||
return "string"
|
||||
case "list":
|
||||
return "array"
|
||||
default:
|
||||
return f.Type
|
||||
}
|
||||
}
|
||||
|
||||
// coerceLiteral converts a meta_data literal (default/enum/example) to the
|
||||
// field's canonical type. Literals may arrive as strings (meta_data's usual
|
||||
// form) OR already typed — a JSON number unmarshals to float64, a JSON bool to
|
||||
// bool — so both must be normalized to the SAME Go type the canonical type
|
||||
// implies (int64 for "integer", float64 for "number", bool for "boolean").
|
||||
// Otherwise enumLess, which type-asserts on that Go type, can't order the
|
||||
// values. Returns (value, true) on success, (nil, false) when the literal
|
||||
// cannot be represented in the declared type.
|
||||
func coerceLiteral(canonicalType string, raw any) (any, bool) {
|
||||
switch canonicalType {
|
||||
case "integer":
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return n, true
|
||||
}
|
||||
case float64: // JSON number; accept only when it's a whole value
|
||||
if v == float64(int64(v)) {
|
||||
return int64(v), true
|
||||
}
|
||||
case int64:
|
||||
return v, true
|
||||
case int:
|
||||
return int64(v), true
|
||||
}
|
||||
return nil, false
|
||||
case "number":
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return n, true
|
||||
}
|
||||
case float64:
|
||||
return v, true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return nil, false
|
||||
case "boolean":
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
switch v {
|
||||
case "true":
|
||||
return true, true
|
||||
case "false":
|
||||
return false, true
|
||||
}
|
||||
case bool:
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
default: // "string", "array", "" (objects), or unknown — pass through as-is
|
||||
return raw, true
|
||||
}
|
||||
}
|
||||
|
||||
// enumLess orders two coerced enum values for the canonical type, so integer
|
||||
// enums end up [1 2 10] not lexicographic [1 10 2].
|
||||
func enumLess(canonicalType string, a, b any) bool {
|
||||
switch canonicalType {
|
||||
case "integer":
|
||||
ai, _ := a.(int64)
|
||||
bi, _ := b.(int64)
|
||||
return ai < bi
|
||||
case "number":
|
||||
af, _ := a.(float64)
|
||||
bf, _ := b.(float64)
|
||||
return af < bf
|
||||
case "boolean":
|
||||
ab, _ := a.(bool)
|
||||
bb, _ := b.(bool)
|
||||
return !ab && bb
|
||||
default:
|
||||
as, _ := a.(string)
|
||||
bs, _ := b.(string)
|
||||
return as < bs
|
||||
}
|
||||
}
|
||||
|
||||
// EnumOption is one allowed value paired with its human description. The
|
||||
// description comes from options[].description and is empty for the bare `enum`
|
||||
// form (which carries no descriptions).
|
||||
type EnumOption struct {
|
||||
Value any
|
||||
Description string
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
func (f Field) EnumOptions() []EnumOption {
|
||||
ct := f.CanonicalType()
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
seen := make(map[string]bool)
|
||||
for _, o := range f.Options {
|
||||
key := fmt.Sprintf("%v", o.Value)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if v, ok := coerceLiteral(ct, o.Value); ok {
|
||||
out = append(out, EnumOption{Value: v, Description: o.Description})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 && ct != "string" && ct != "" {
|
||||
sort.SliceStable(out, func(i, j int) bool { return enumLess(ct, out[i].Value, out[j].Value) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnumValues returns the field's allowed values — the value projection of
|
||||
// EnumOptions, in the same order. nil when the field declares no enum
|
||||
// constraint. (Kept as the values-only accessor for the envelope and flag
|
||||
// completion, which don't need descriptions.)
|
||||
func (f Field) EnumValues() []any {
|
||||
opts := f.EnumOptions()
|
||||
if len(opts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]any, len(opts))
|
||||
for i, o := range opts {
|
||||
out[i] = o.Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CoercedDefault returns Default coerced to the canonical type, or nil when the
|
||||
// field has no default or the literal cannot be coerced.
|
||||
func (f Field) CoercedDefault() any { return f.coerce(f.Default) }
|
||||
|
||||
// CoercedExample returns Example coerced to the canonical type, or nil when the
|
||||
// field has no example or the literal cannot be coerced.
|
||||
func (f Field) CoercedExample() any { return f.coerce(f.Example) }
|
||||
|
||||
func (f Field) coerce(raw any) any {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := coerceLiteral(f.CanonicalType(), raw); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MinBound returns the field's min constraint as a number, or nil when absent
|
||||
// or unparseable. meta_data carries min/max as strings and does not say
|
||||
// whether they bound a value or a string's length; the accessors stay equally
|
||||
// agnostic, so every renderer (envelope minimum/maximum, flag help) presents
|
||||
// the same numbers without inventing a semantic the source doesn't declare.
|
||||
func (f Field) MinBound() *float64 { return parseBound(f.Min) }
|
||||
|
||||
// MaxBound returns the field's max constraint as a number, or nil when absent
|
||||
// or unparseable. See MinBound.
|
||||
func (f Field) MaxBound() *float64 { return parseBound(f.Max) }
|
||||
|
||||
func parseBound(s string) *float64 {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if v, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
160
internal/meta/normalize_test.go
Normal file
160
internal/meta/normalize_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestField_CanonicalType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"file": "string", // meta_data's non-standard "file" is a string with binary format
|
||||
"list": "array", // "list" is meta_data's spelling of a JSON array
|
||||
"integer": "integer",
|
||||
"boolean": "boolean",
|
||||
"string": "string",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := (Field{Type: in}).CanonicalType(); got != want {
|
||||
t.Errorf("CanonicalType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumValues(t *testing.T) {
|
||||
// string enum keeps source order (order can encode priority)
|
||||
if got := (Field{Type: "string", Enum: []any{"b", "a", "c"}}).EnumValues(); !reflect.DeepEqual(got, []any{"b", "a", "c"}) {
|
||||
t.Errorf("string enum = %v, want source order [b a c]", got)
|
||||
}
|
||||
// integer enum: string-stored literals coerced to int64 and numerically sorted
|
||||
if got := (Field{Type: "integer", Enum: []any{"10", "2", "1"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) {
|
||||
t.Errorf("integer enum = %v, want [1 2 10] coerced+sorted", got)
|
||||
}
|
||||
// options used when enum absent, deduped, source order for strings
|
||||
if got := (Field{Type: "string", Options: []Option{{Value: "x"}, {Value: "x"}, {Value: "y"}}}).EnumValues(); !reflect.DeepEqual(got, []any{"x", "y"}) {
|
||||
t.Errorf("options enum = %v, want [x y] deduped", got)
|
||||
}
|
||||
// uncoercible literal dropped
|
||||
if got := (Field{Type: "integer", Enum: []any{"1", "nope", "2"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2)}) {
|
||||
t.Errorf("bad enum = %v, want [1 2] (nope dropped)", got)
|
||||
}
|
||||
// no enum/options -> nil
|
||||
if got := (Field{Type: "string"}).EnumValues(); got != nil {
|
||||
t.Errorf("empty enum = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions(t *testing.T) {
|
||||
// options carry descriptions, kept paired with their (string) value in source order
|
||||
fo := Field{Type: "string", Options: []Option{
|
||||
{Value: "open_id", Description: "以 open_id 标识"},
|
||||
{Value: "open_id", Description: "dup ignored"}, // dedup keeps first
|
||||
{Value: "user_id", Description: "以 user_id 标识"},
|
||||
}}
|
||||
got := fo.EnumOptions()
|
||||
want := []EnumOption{
|
||||
{Value: "open_id", Description: "以 open_id 标识"},
|
||||
{Value: "user_id", Description: "以 user_id 标识"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("EnumOptions = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// integer enum (bare form): values coerced + numerically sorted, no descriptions
|
||||
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}}
|
||||
gi := fi.EnumOptions()
|
||||
if len(gi) != 3 || gi[0].Value != int64(1) || gi[2].Value != int64(10) || gi[0].Description != "" {
|
||||
t.Errorf("EnumOptions(integer) = %+v, want [1 2 10] coerced+sorted, no desc", gi)
|
||||
}
|
||||
|
||||
// EnumValues stays the value projection of EnumOptions (golden-critical)
|
||||
if !reflect.DeepEqual(fo.EnumValues(), []any{"open_id", "user_id"}) {
|
||||
t.Errorf("EnumValues diverged from EnumOptions values: %v", fo.EnumValues())
|
||||
}
|
||||
// unconstrained -> nil
|
||||
if (Field{Type: "string"}).EnumOptions() != nil {
|
||||
t.Error("EnumOptions should be nil when unconstrained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Enum_NumberAndBoolean(t *testing.T) {
|
||||
// number: string-stored floats coerced to float64 and numerically sorted
|
||||
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
|
||||
t.Errorf("number enum = %v, want [1.5 2.5 10] coerced+sorted", got)
|
||||
}
|
||||
// number: uncoercible literal dropped
|
||||
if got := (Field{Type: "number", Enum: []any{"1.5", "x", "2.5"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5}) {
|
||||
t.Errorf("number enum with bad value = %v, want [1.5 2.5]", got)
|
||||
}
|
||||
// boolean: true/false coerced and sorted (false before true); invalid dropped
|
||||
if got := (Field{Type: "boolean", Enum: []any{"true", "maybe", "false"}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) {
|
||||
t.Errorf("boolean enum = %v, want [false true]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions_NonStringValuesNormalized(t *testing.T) {
|
||||
// JSON numbers/bools arrive already-typed (a number is float64, not a
|
||||
// string) — e.g. options[].value: 0. They must still be normalized to the
|
||||
// field's canonical type (int64 for "integer") and sorted numerically;
|
||||
// leaving them as float64 both yields the wrong type and defeats enumLess,
|
||||
// whose integer branch asserts int64 and would otherwise treat every value
|
||||
// as zero (no sort).
|
||||
if got := (Field{Type: "integer", Options: []Option{
|
||||
{Value: float64(10)}, {Value: float64(2)}, {Value: float64(1)},
|
||||
}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) {
|
||||
t.Errorf("integer options from float64 = %#v, want [int64(1) int64(2) int64(10)]", got)
|
||||
}
|
||||
// bare enum form, JSON numbers
|
||||
if got := (Field{Type: "integer", Enum: []any{float64(3), float64(1), float64(2)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(3)}) {
|
||||
t.Errorf("integer enum from float64 = %#v, want [int64(1) int64(2) int64(3)]", got)
|
||||
}
|
||||
// number field: a whole-valued float stays float64
|
||||
if got := (Field{Type: "number", Enum: []any{float64(2), float64(1)}}).EnumValues(); !reflect.DeepEqual(got, []any{float64(1), float64(2)}) {
|
||||
t.Errorf("number enum from float64 = %#v, want [float64(1) float64(2)]", got)
|
||||
}
|
||||
// boolean field: native bools coerce + sort (false < true)
|
||||
if got := (Field{Type: "boolean", Enum: []any{true, false}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) {
|
||||
t.Errorf("boolean enum from bool = %#v, want [false true]", got)
|
||||
}
|
||||
// non-integral float under integer is uncoercible -> dropped (mirrors how "2.5" fails ParseInt)
|
||||
if got := (Field{Type: "integer", Enum: []any{float64(1), float64(2.5), float64(3)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(3)}) {
|
||||
t.Errorf("integer enum with fractional float = %#v, want [int64(1) int64(3)]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_CoercedDefaultAndExample(t *testing.T) {
|
||||
if got := (Field{Type: "integer", Default: "5"}).CoercedDefault(); got != int64(5) {
|
||||
t.Errorf("CoercedDefault integer = %v (%T), want int64(5)", got, got)
|
||||
}
|
||||
if got := (Field{Type: "integer", Default: "bad"}).CoercedDefault(); got != nil {
|
||||
t.Errorf("CoercedDefault uncoercible = %v, want nil", got)
|
||||
}
|
||||
if got := (Field{Type: "string"}).CoercedDefault(); got != nil {
|
||||
t.Errorf("CoercedDefault absent = %v, want nil", got)
|
||||
}
|
||||
if got := (Field{Type: "boolean", Example: "true"}).CoercedExample(); got != true {
|
||||
t.Errorf("CoercedExample boolean = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Bounds(t *testing.T) {
|
||||
f := Field{Min: "1", Max: "100"}
|
||||
if v := f.MinBound(); v == nil || *v != 1 {
|
||||
t.Errorf("MinBound = %v, want 1", v)
|
||||
}
|
||||
if v := f.MaxBound(); v == nil || *v != 100 {
|
||||
t.Errorf("MaxBound = %v, want 100", v)
|
||||
}
|
||||
if v := (Field{Min: "0.5"}).MinBound(); v == nil || *v != 0.5 {
|
||||
t.Errorf("MinBound fractional = %v, want 0.5", v)
|
||||
}
|
||||
if v := (Field{}).MinBound(); v != nil {
|
||||
t.Errorf("MinBound absent = %v, want nil", v)
|
||||
}
|
||||
if v := (Field{Max: "not_a_number"}).MaxBound(); v != nil {
|
||||
t.Errorf("MaxBound unparseable = %v, want nil", v)
|
||||
}
|
||||
}
|
||||
20
internal/registry/catalog.go
Normal file
20
internal/registry/catalog.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import "github.com/larksuite/cli/internal/apicatalog"
|
||||
|
||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
||||
// and schema lint.
|
||||
func EmbeddedCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||
}
|
||||
|
||||
// RuntimeCatalog returns a navigation catalog over the merged (embedded + remote
|
||||
// overlay) metadata — for service command registration and scope discovery,
|
||||
// where overlay methods must be reachable.
|
||||
func RuntimeCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||
}
|
||||
@@ -3,54 +3,17 @@
|
||||
|
||||
package registry
|
||||
|
||||
// GetStrFromMap extracts a string value from map[string]interface{}.
|
||||
func GetStrFromMap(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
import "github.com/larksuite/cli/internal/meta"
|
||||
|
||||
// GetStrSliceFromMap extracts a []string value from map[string]interface{}.
|
||||
// Returns nil if the key is missing or the value is not a string slice.
|
||||
func GetStrSliceFromMap(m map[string]interface{}, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DeclaredScopesForMethod returns the scopes declared by a method's
|
||||
// from_meta entry for the given identity. Prefers the explicit
|
||||
// `requiredScopes` field when present; otherwise returns the single
|
||||
// recommended scope from `scopes` (or the first scope as a final fallback).
|
||||
// Returns nil when the method has no scope information.
|
||||
func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
||||
if method == nil {
|
||||
return nil
|
||||
}
|
||||
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
||||
out := make([]string, 0, len(requiredRaw))
|
||||
for _, v := range requiredRaw {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
// DeclaredScopesForMethod returns the scopes declared by a method for the given
|
||||
// identity. Prefers the explicit `requiredScopes` field when present; otherwise
|
||||
// returns the single recommended scope from `scopes` (or the first scope as a
|
||||
// final fallback). Returns nil when the method has no scope information.
|
||||
func DeclaredScopesForMethod(m meta.Method, identity string) []string {
|
||||
if len(m.RequiredScopes) > 0 {
|
||||
out := make([]string, 0, len(m.RequiredScopes))
|
||||
for _, s := range m.RequiredScopes {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
@@ -58,54 +21,11 @@ func DeclaredScopesForMethod(method map[string]interface{}, identity string) []s
|
||||
return out
|
||||
}
|
||||
}
|
||||
rawScopes, _ := method["scopes"].([]interface{})
|
||||
if len(rawScopes) == 0 {
|
||||
if len(m.Scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
recommended := SelectRecommendedScope(rawScopes, identity)
|
||||
if recommended == "" {
|
||||
for _, raw := range rawScopes {
|
||||
if s, ok := raw.(string); ok && s != "" {
|
||||
recommended = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if recommended := SelectRecommendedScopeFromStrings(m.Scopes, identity); recommended != "" {
|
||||
return []string{recommended}
|
||||
}
|
||||
if recommended == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{recommended}
|
||||
}
|
||||
|
||||
// SelectRecommendedScope selects the known scope with the highest priority score
|
||||
// (higher = more recommended / least privilege).
|
||||
// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes.
|
||||
func SelectRecommendedScope(scopes []interface{}, identity string) string {
|
||||
priorities := LoadScopePriorities()
|
||||
bestScore := -1
|
||||
bestScope := ""
|
||||
for _, s := range scopes {
|
||||
str, ok := s.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
score, exists := priorities[str]
|
||||
if !exists {
|
||||
continue // skip unknown scopes
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestScope = str
|
||||
}
|
||||
}
|
||||
if bestScope != "" {
|
||||
return bestScope
|
||||
}
|
||||
// Fallback: if no scope is in the priority table, return the first one.
|
||||
if len(scopes) > 0 {
|
||||
if s, ok := scopes[0].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
@@ -22,68 +23,42 @@ var registryFS embed.FS
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
embeddedServices []meta.Service // parsed once, sorted by name (no overlay)
|
||||
embeddedServicesByName map[string]meta.Service // same, keyed by name
|
||||
embeddedVersion string // version from embedded meta_data.json
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
// parseEmbedded decodes the embedded meta_data.json into the typed model exactly
|
||||
// once. It is the single parse of the embedded bytes: both the overlay-free
|
||||
// envelope path (EmbeddedServicesTyped) and the merged command/scope path
|
||||
// (loadEmbeddedIntoMerged) build from this result, so the JSON is never parsed
|
||||
// twice and no map round-trip is needed downstream.
|
||||
func parseEmbedded() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
reg, _ := meta.Parse(embeddedMetaJSON)
|
||||
embeddedVersion = reg.Version
|
||||
embeddedServices = reg.Services
|
||||
sort.Slice(embeddedServices, func(i, j int) bool { return embeddedServices[i].Name < embeddedServices[j].Name })
|
||||
embeddedServicesByName = make(map[string]meta.Service, len(embeddedServices))
|
||||
for _, svc := range embeddedServices {
|
||||
embeddedServicesByName[svc.Name] = svc
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
return out
|
||||
// EmbeddedServicesTyped returns the embedded services (no remote overlay) as the
|
||||
// typed meta model, sorted by name. This is the overlay-free parse boundary the
|
||||
// schema envelope builds from — deterministic across machines.
|
||||
func EmbeddedServicesTyped() []meta.Service {
|
||||
parseEmbedded()
|
||||
return embeddedServices
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
embeddedVersion string // version from embedded meta_data.json
|
||||
mergedServices = make(map[string]meta.Service) // project name → typed service (embedded + overlay)
|
||||
mergedProjectList []string // sorted project names
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
@@ -106,8 +81,8 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
// 2. Remote overlay
|
||||
if remoteEnabled() && cacheWritable() {
|
||||
// Check if brand changed since last cache
|
||||
meta, metaErr := loadCacheMeta()
|
||||
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
|
||||
cm, metaErr := loadCacheMeta()
|
||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
@@ -117,7 +92,7 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
if len(mergedServices) == 0 || brandChanged {
|
||||
// No data at all or brand changed — must sync fetch
|
||||
doSyncFetch()
|
||||
} else if shouldRefresh(meta) || metaErr != nil {
|
||||
} else if shouldRefresh(cm) || metaErr != nil {
|
||||
// Have embedded/cached data; refresh in background if TTL expired or first run
|
||||
triggerBackgroundRefresh()
|
||||
}
|
||||
@@ -127,18 +102,13 @@ func InitWithBrand(brand core.LarkBrand) {
|
||||
})
|
||||
}
|
||||
|
||||
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
|
||||
// mergedServices. No-op if meta_data.json is not compiled in.
|
||||
// loadEmbeddedIntoMerged seeds mergedServices from the embedded typed services
|
||||
// (the same parse EmbeddedServicesTyped uses). No-op if no services compiled in.
|
||||
func loadEmbeddedIntoMerged() {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
parseEmbedded()
|
||||
for name, svc := range embeddedServicesByName {
|
||||
mergedServices[name] = svc
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return
|
||||
}
|
||||
embeddedVersion = reg.Version
|
||||
overlayMergedServices(®)
|
||||
}
|
||||
|
||||
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
|
||||
@@ -150,83 +120,32 @@ func rebuildProjectList() {
|
||||
sort.Strings(mergedProjectList)
|
||||
}
|
||||
|
||||
var cachedAllScopes map[string][]string
|
||||
var (
|
||||
servicesTyped []meta.Service
|
||||
servicesTypedOnce sync.Once
|
||||
)
|
||||
|
||||
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
|
||||
// for the given identity ("user" or "tenant"). Results are deduplicated and sorted.
|
||||
func CollectAllScopesFromMeta(identity string) []string {
|
||||
if cachedAllScopes == nil {
|
||||
cachedAllScopes = make(map[string][]string)
|
||||
}
|
||||
if cached, ok := cachedAllScopes[identity]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, project := range ListFromMetaProjects() {
|
||||
spec := LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
continue
|
||||
// ServicesTyped returns the merged registry (embedded + remote overlay) as typed
|
||||
// meta.Services, sorted by name. The merged store is already typed, so this just
|
||||
// projects it into a sorted slice — no map round-trip. This is the typed entry
|
||||
// the command tree and scope computation build from.
|
||||
func ServicesTyped() []meta.Service {
|
||||
servicesTypedOnce.Do(func() {
|
||||
Init()
|
||||
servicesTyped = make([]meta.Service, 0, len(mergedProjectList))
|
||||
for _, name := range mergedProjectList {
|
||||
servicesTyped = append(servicesTyped, mergedServices[name])
|
||||
}
|
||||
resources, ok := spec["resources"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, resSpec := range resources {
|
||||
resMap, ok := resSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, ok := resMap["methods"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, methodSpec := range methods {
|
||||
methodMap, ok := methodSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Check if method supports the requested identity
|
||||
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
|
||||
supported := false
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Collect scopes
|
||||
scopes, ok := methodMap["scopes"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, s := range scopes {
|
||||
if str, ok := s.(string); ok {
|
||||
scopeSet[str] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
}
|
||||
sort.Strings(result)
|
||||
cachedAllScopes[identity] = result
|
||||
return result
|
||||
})
|
||||
return servicesTyped
|
||||
}
|
||||
|
||||
// LoadFromMeta loads a service schema by project name.
|
||||
// It returns data from the merged registry (embedded + cached remote overlay).
|
||||
func LoadFromMeta(project string) map[string]interface{} {
|
||||
// ServiceTyped returns one merged service (embedded + overlay) by name, or false
|
||||
// if unknown.
|
||||
func ServiceTyped(name string) (meta.Service, bool) {
|
||||
Init()
|
||||
return mergedServices[project]
|
||||
svc, ok := mergedServices[name]
|
||||
return svc, ok
|
||||
}
|
||||
|
||||
// ListFromMetaProjects lists available service project names (sorted).
|
||||
|
||||
@@ -65,8 +65,7 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) {
|
||||
}
|
||||
t.Logf("%s=%d, %s=%d", scopeA, scoreA, scopeB, scoreB)
|
||||
|
||||
scopes := []interface{}{scopeB, scopeA}
|
||||
result := SelectRecommendedScope(scopes, "user")
|
||||
result := bestScope([]string{scopeB, scopeA}, priorities)
|
||||
|
||||
// Should pick the higher-scored one (higher = more recommended)
|
||||
if scoreA > scoreB {
|
||||
@@ -81,11 +80,11 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) {
|
||||
scopes := []interface{}{
|
||||
scopes := []string{
|
||||
"zzz_unknown:scope:a",
|
||||
"zzz_unknown:scope:b",
|
||||
}
|
||||
result := SelectRecommendedScope(scopes, "user")
|
||||
result := bestScope(scopes, LoadScopePriorities())
|
||||
// All unknown scopes get DefaultScopeScore; first one with that score wins
|
||||
if result != "zzz_unknown:scope:a" {
|
||||
t.Errorf("expected zzz_unknown:scope:a, got %s", result)
|
||||
@@ -93,13 +92,10 @@ func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSelectRecommendedScope_Empty(t *testing.T) {
|
||||
result := SelectRecommendedScope(nil, "user")
|
||||
if result != "" {
|
||||
if result := bestScope(nil, LoadScopePriorities()); result != "" {
|
||||
t.Errorf("expected empty string, got %s", result)
|
||||
}
|
||||
|
||||
result = SelectRecommendedScope([]interface{}{}, "user")
|
||||
if result != "" {
|
||||
if result := bestScope([]string{}, LoadScopePriorities()); result != "" {
|
||||
t.Errorf("expected empty string, got %s", result)
|
||||
}
|
||||
}
|
||||
@@ -318,27 +314,6 @@ func TestFilterAutoApproveScopes_Empty(t *testing.T) {
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func TestGetStrFromMap(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"key3": nil,
|
||||
}
|
||||
|
||||
if v := GetStrFromMap(m, "key1"); v != "value1" {
|
||||
t.Errorf("expected value1, got %s", v)
|
||||
}
|
||||
if v := GetStrFromMap(m, "key2"); v != "" {
|
||||
t.Errorf("expected empty for non-string value, got %s", v)
|
||||
}
|
||||
if v := GetStrFromMap(m, "missing"); v != "" {
|
||||
t.Errorf("expected empty for missing key, got %s", v)
|
||||
}
|
||||
if v := GetStrFromMap(nil, "key"); v != "" {
|
||||
t.Errorf("expected empty for nil map, got %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegistryDir(t *testing.T) {
|
||||
dir := GetRegistryDir()
|
||||
if dir == "" {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -35,16 +36,20 @@ type CacheMeta struct {
|
||||
Brand string `json:"brand,omitempty"`
|
||||
}
|
||||
|
||||
// MergedRegistry is the top-level structure of remote_meta.json.
|
||||
// MergedRegistry is the top-level structure of remote_meta.json. Services are
|
||||
// decoded straight into the typed meta model so embedded, cached, and remote
|
||||
// data share one representation (no map intermediary, no re-parse).
|
||||
type MergedRegistry struct {
|
||||
Version string `json:"version"`
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
Version string `json:"version"`
|
||||
Services []meta.Service `json:"services"`
|
||||
}
|
||||
|
||||
// remoteResponse is the envelope returned by the remote API.
|
||||
// remoteResponse is the envelope returned by the remote API. Data stays raw:
|
||||
// the cache must store the server payload verbatim — a typed re-marshal would
|
||||
// strip every field the model doesn't (yet) declare and starve future binaries.
|
||||
type remoteResponse struct {
|
||||
Msg string `json:"msg"`
|
||||
Data MergedRegistry `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// configuredBrand is set by InitWithBrand and determines which API host to use.
|
||||
@@ -125,22 +130,22 @@ func cacheWritable() bool {
|
||||
// --- cache I/O ---
|
||||
|
||||
func loadCacheMeta() (CacheMeta, error) {
|
||||
var meta CacheMeta
|
||||
var cm CacheMeta
|
||||
data, err := vfs.ReadFile(cacheMetaPath())
|
||||
if err != nil {
|
||||
return meta, err
|
||||
return cm, err
|
||||
}
|
||||
if err = json.Unmarshal(data, &meta); err != nil {
|
||||
return meta, err
|
||||
if err = json.Unmarshal(data, &cm); err != nil {
|
||||
return cm, err
|
||||
}
|
||||
return meta, nil
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
func saveCacheMeta(meta CacheMeta) error {
|
||||
func saveCacheMeta(cm CacheMeta) error {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(meta)
|
||||
data, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,14 +168,16 @@ func loadCachedMerged() (*MergedRegistry, error) {
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
// saveCachedMerged writes the cache file. data must be the server's data
|
||||
// payload verbatim (see remoteResponse) — never a typed re-marshal.
|
||||
func saveCachedMerged(data []byte, cm CacheMeta) error {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveCacheMeta(meta)
|
||||
return saveCacheMeta(cm)
|
||||
}
|
||||
|
||||
// --- HTTP fetch ---
|
||||
@@ -211,18 +218,21 @@ func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, e
|
||||
return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg)
|
||||
}
|
||||
|
||||
// If data.Services is nil, the version is up-to-date (not modified)
|
||||
if envelope.Data.Services == nil {
|
||||
var parsed MergedRegistry
|
||||
if len(envelope.Data) > 0 {
|
||||
if err := json.Unmarshal(envelope.Data, &parsed); err != nil {
|
||||
return nil, nil, fmt.Errorf("remote meta: parse data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If data.services is nil, the version is up-to-date (not modified)
|
||||
if parsed.Services == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Re-marshal just the data portion for caching
|
||||
dataBytes, err := json.Marshal(envelope.Data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return dataBytes, &envelope.Data, nil
|
||||
// Cache the data portion verbatim (see remoteResponse for why not a typed
|
||||
// re-marshal).
|
||||
return envelope.Data, &parsed, nil
|
||||
}
|
||||
|
||||
type httpError struct {
|
||||
@@ -247,12 +257,12 @@ func doSyncFetch() {
|
||||
})
|
||||
return
|
||||
}
|
||||
meta := CacheMeta{
|
||||
cm := CacheMeta{
|
||||
LastCheckAt: time.Now().Unix(),
|
||||
Version: reg.Version,
|
||||
Brand: string(configuredBrand),
|
||||
}
|
||||
_ = saveCachedMerged(data, meta)
|
||||
_ = saveCachedMerged(data, cm)
|
||||
overlayMergedServices(reg)
|
||||
}
|
||||
|
||||
@@ -275,22 +285,22 @@ func triggerBackgroundRefresh() {
|
||||
|
||||
func doBackgroundRefresh() {
|
||||
defer func() { _ = recover() }()
|
||||
meta, _ := loadCacheMeta()
|
||||
version := meta.Version
|
||||
cm, _ := loadCacheMeta()
|
||||
version := cm.Version
|
||||
if version == "" {
|
||||
version = embeddedVersion
|
||||
}
|
||||
data, reg, err := fetchRemoteMerged(version)
|
||||
if err != nil {
|
||||
// On error, update last_check_at to avoid retrying every invocation
|
||||
meta.LastCheckAt = time.Now().Unix()
|
||||
_ = saveCacheMeta(meta)
|
||||
cm.LastCheckAt = time.Now().Unix()
|
||||
_ = saveCacheMeta(cm)
|
||||
return
|
||||
}
|
||||
if reg == nil {
|
||||
// Version unchanged — just update check time
|
||||
meta.LastCheckAt = time.Now().Unix()
|
||||
_ = saveCacheMeta(meta)
|
||||
cm.LastCheckAt = time.Now().Unix()
|
||||
_ = saveCacheMeta(cm)
|
||||
return
|
||||
}
|
||||
newMeta := CacheMeta{
|
||||
@@ -302,21 +312,20 @@ func doBackgroundRefresh() {
|
||||
}
|
||||
|
||||
// shouldRefresh returns true if the cache TTL has expired.
|
||||
func shouldRefresh(meta CacheMeta) bool {
|
||||
if meta.LastCheckAt == 0 {
|
||||
func shouldRefresh(cm CacheMeta) bool {
|
||||
if cm.LastCheckAt == 0 {
|
||||
return true
|
||||
}
|
||||
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
|
||||
return time.Since(time.Unix(cm.LastCheckAt, 0)) > metaTTL()
|
||||
}
|
||||
|
||||
// overlayMergedServices merges remote services into the in-memory map.
|
||||
// Remote entries override embedded entries with the same name.
|
||||
func overlayMergedServices(reg *MergedRegistry) {
|
||||
for _, svc := range reg.Services {
|
||||
name, ok := svc["name"].(string)
|
||||
if !ok || name == "" {
|
||||
if svc.Name == "" {
|
||||
continue
|
||||
}
|
||||
mergedServices[name] = svc
|
||||
mergedServices[svc.Name] = svc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// waitBackgroundRefresh blocks until any in-flight background refresh started by
|
||||
@@ -30,7 +31,10 @@ func resetInit() {
|
||||
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
embeddedParseOnce = sync.Once{}
|
||||
servicesTypedOnce = sync.Once{}
|
||||
servicesTyped = nil
|
||||
mergedServices = make(map[string]meta.Service)
|
||||
mergedProjectList = nil
|
||||
embeddedVersion = ""
|
||||
cachedAllScopes = nil
|
||||
@@ -71,13 +75,12 @@ func hasEmbeddedServices() bool {
|
||||
func testRegistry(name string) MergedRegistry {
|
||||
return MergedRegistry{
|
||||
Version: "test-1.0",
|
||||
Services: []map[string]interface{}{
|
||||
Services: []meta.Service{
|
||||
{
|
||||
"name": name,
|
||||
"version": "v1",
|
||||
"title": name + " API",
|
||||
"servicePath": "/open-apis/" + name + "/v1",
|
||||
"resources": map[string]interface{}{},
|
||||
Name: name,
|
||||
Version: "v1",
|
||||
Title: name + " API",
|
||||
ServicePath: "/open-apis/" + name + "/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -91,9 +94,10 @@ func testCacheJSON(name string) []byte {
|
||||
|
||||
// testEnvelopeJSON returns the remote API envelope format: {"msg":"succeeded","data":{...}}.
|
||||
func testEnvelopeJSON(name string) []byte {
|
||||
regData, _ := json.Marshal(testRegistry(name))
|
||||
resp := remoteResponse{
|
||||
Msg: "succeeded",
|
||||
Data: testRegistry(name),
|
||||
Data: regData,
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return data
|
||||
@@ -131,7 +135,7 @@ func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) {
|
||||
|
||||
Init()
|
||||
|
||||
if spec := LoadFromMeta("remote_calendar"); spec == nil {
|
||||
if _, ok := ServiceTyped("remote_calendar"); !ok {
|
||||
t.Fatal("expected remote_calendar from sync fetch")
|
||||
}
|
||||
}
|
||||
@@ -150,7 +154,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
||||
Init()
|
||||
|
||||
// "fake_remote_svc" should not be loaded when remote is off
|
||||
if spec := LoadFromMeta("fake_remote_svc"); spec != nil {
|
||||
if _, ok := ServiceTyped("fake_remote_svc"); ok {
|
||||
t.Error("expected fake_remote_svc to NOT be loaded when remote is off")
|
||||
}
|
||||
}
|
||||
@@ -181,12 +185,12 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
||||
Init()
|
||||
|
||||
// custom_svc should be loaded from cache overlay
|
||||
if spec := LoadFromMeta("custom_svc"); spec == nil {
|
||||
if _, ok := ServiceTyped("custom_svc"); !ok {
|
||||
t.Error("expected custom_svc from cache overlay")
|
||||
}
|
||||
// Embedded projects should still be present (if compiled in)
|
||||
if hasEmbeddedServices() {
|
||||
if spec := LoadFromMeta("calendar"); spec == nil {
|
||||
if _, ok := ServiceTyped("calendar"); !ok {
|
||||
t.Error("expected calendar from embedded data")
|
||||
}
|
||||
}
|
||||
@@ -227,7 +231,7 @@ func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||
if len(projects) == 0 {
|
||||
t.Fatal("expected projects after network error")
|
||||
}
|
||||
if spec := LoadFromMeta("cached_svc"); spec == nil {
|
||||
if _, ok := ServiceTyped("cached_svc"); !ok {
|
||||
t.Fatal("expected cached_svc after network error")
|
||||
}
|
||||
|
||||
@@ -304,19 +308,19 @@ func TestMetaTTL(t *testing.T) {
|
||||
|
||||
func TestOverlayMergedServices(t *testing.T) {
|
||||
resetInit()
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
|
||||
mergedServices = make(map[string]meta.Service)
|
||||
mergedServices["existing"] = meta.Service{Name: "existing", Version: "v1"}
|
||||
|
||||
reg := &MergedRegistry{
|
||||
Services: []map[string]interface{}{
|
||||
{"name": "existing", "version": "v2"},
|
||||
{"name": "brand_new", "version": "v1"},
|
||||
Services: []meta.Service{
|
||||
{Name: "existing", Version: "v2"},
|
||||
{Name: "brand_new", Version: "v1"},
|
||||
},
|
||||
}
|
||||
overlayMergedServices(reg)
|
||||
|
||||
// existing should be overridden
|
||||
if v := mergedServices["existing"]["version"].(string); v != "v2" {
|
||||
if v := mergedServices["existing"].Version; v != "v2" {
|
||||
t.Errorf("expected existing to be overridden to v2, got %s", v)
|
||||
}
|
||||
// brand_new should be added
|
||||
@@ -333,18 +337,18 @@ func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
const leakedExisting = "test_isolation_existing_sentinel"
|
||||
const leakedOverlay = "test_isolation_overlay_sentinel"
|
||||
|
||||
mergedServices = map[string]map[string]interface{}{
|
||||
leakedExisting: {"name": leakedExisting, "version": "v1"},
|
||||
mergedServices = map[string]meta.Service{
|
||||
leakedExisting: {Name: leakedExisting, Version: "v1"},
|
||||
}
|
||||
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
|
||||
overlayMergedServices(&MergedRegistry{Services: []meta.Service{{Name: leakedOverlay, Version: "v1"}}})
|
||||
|
||||
resetInit()
|
||||
Init()
|
||||
|
||||
if spec := LoadFromMeta(leakedExisting); spec != nil {
|
||||
if _, ok := ServiceTyped(leakedExisting); ok {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
|
||||
}
|
||||
if spec := LoadFromMeta(leakedOverlay); spec != nil {
|
||||
if _, ok := ServiceTyped(leakedOverlay); ok {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
|
||||
}
|
||||
}
|
||||
@@ -372,6 +376,31 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchRemoteMerged_CacheBytesPreserveUnmodeledKeys pins that the bytes
|
||||
// destined for the on-disk cache are the server's data payload verbatim: a key
|
||||
// the typed model does not (yet) declare must survive (see remoteResponse for
|
||||
// why).
|
||||
func TestFetchRemoteMerged_CacheBytesPreserveUnmodeledKeys(t *testing.T) {
|
||||
const payload = `{"msg":"succeeded","data":{"version":"test-1.0","services":[{"name":"svc","servicePath":"/open-apis/svc/v1","resources":{"items":{"methods":{"list":{"httpMethod":"GET","path":"items","parameters":{"status":{"type":"string","enumName":"StatusEnum"}}}}}}}]}}`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(payload))
|
||||
}))
|
||||
defer ts.Close()
|
||||
testMetaURL = ts.URL
|
||||
|
||||
data, reg, err := fetchRemoteMerged("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg == nil || len(reg.Services) != 1 || reg.Services[0].Name != "svc" {
|
||||
t.Fatalf("typed registry not decoded, got %+v", reg)
|
||||
}
|
||||
if !strings.Contains(string(data), `"enumName":"StatusEnum"`) {
|
||||
t.Errorf("cache payload dropped unmodeled key enumName, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRemoteMerged_VersionMatch(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
@@ -484,7 +513,7 @@ func TestBrandSwitchInvalidatesCache(t *testing.T) {
|
||||
|
||||
// The old feishu_svc should NOT be loaded from stale cache
|
||||
// The new lark_svc from sync fetch should be available
|
||||
if spec := LoadFromMeta("lark_svc"); spec == nil {
|
||||
if _, ok := ServiceTyped("lark_svc"); !ok {
|
||||
t.Error("expected lark_svc after brand switch sync fetch")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,22 +44,12 @@ func ExtractRequiredScopes(detail interface{}) []string {
|
||||
return scopes
|
||||
}
|
||||
|
||||
// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper
|
||||
// around SelectRecommendedScope. When no scope is recognized by the priority
|
||||
// table, it falls back to the first input scope so callers always have
|
||||
// something to surface to users.
|
||||
func SelectRecommendedScopeFromStrings(scopes []string, identity string) string {
|
||||
if len(scopes) == 0 {
|
||||
return ""
|
||||
}
|
||||
ifaces := make([]interface{}, len(scopes))
|
||||
for i, s := range scopes {
|
||||
ifaces[i] = s
|
||||
}
|
||||
if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" {
|
||||
return recommended
|
||||
}
|
||||
return scopes[0]
|
||||
// SelectRecommendedScopeFromStrings returns the highest-priority (least-privilege)
|
||||
// scope to surface to users, or "" for no scopes. Unknown scopes score
|
||||
// DefaultScopeScore, so an all-unknown list yields the first entry. Priority is
|
||||
// identity-independent; the parameter is kept for call-site clarity.
|
||||
func SelectRecommendedScopeFromStrings(scopes []string, _ string) string {
|
||||
return bestScope(scopes, LoadScopePriorities())
|
||||
}
|
||||
|
||||
// BuildConsoleScopeURL returns the developer-console "apply scope" URL for the
|
||||
|
||||
@@ -6,16 +6,63 @@ package registry
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// IdentityToAccessToken maps the --identity flag value to the corresponding
|
||||
// accessTokens value used in from_meta JSON files. Bot identity uses
|
||||
// tenant_access_token, so "bot" maps to "tenant".
|
||||
func IdentityToAccessToken(identity string) string {
|
||||
if identity == "bot" {
|
||||
return "tenant"
|
||||
// methodsForProjects walks the runtime catalog once and returns the methods in
|
||||
// the given projects that are reachable by the identity. Catalog navigation is
|
||||
// owned by apicatalog; the collectors below only apply scope policy.
|
||||
func methodsForProjects(projects []string, identity string) []apicatalog.MethodRef {
|
||||
want := make(map[string]bool, len(projects))
|
||||
for _, p := range projects {
|
||||
want[p] = true
|
||||
}
|
||||
return identity
|
||||
wantToken := meta.TokenForIdentity(identity)
|
||||
supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) }
|
||||
// Walk only the requested services (in catalog name order) instead of every
|
||||
// service's methods then discarding the rest.
|
||||
var out []apicatalog.MethodRef
|
||||
for _, svc := range RuntimeCatalog().Services() {
|
||||
if want[svc.Name] {
|
||||
out = append(out, apicatalog.ServiceMethods(svc, supported)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bestScope returns the highest-priority scope from scopes (minimum privilege),
|
||||
// or "" when scopes is empty.
|
||||
func bestScope(scopes []string, priorities map[string]int) string {
|
||||
best := ""
|
||||
bestScore := -1
|
||||
for _, s := range scopes {
|
||||
score := DefaultScopeScore
|
||||
if v, ok := priorities[s]; ok {
|
||||
score = v
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = s
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// FilterForStrictMode returns a method filter enforcing the strict-mode forced
|
||||
// identity, or nil when strict mode is inactive (no filtering). The
|
||||
// token/identity vocabulary (meta.TokenForIdentity) and the "no accessTokens =
|
||||
// permissive" predicate (meta.Method.SupportsToken) both live in meta, so this
|
||||
// only composes them — schema completion/render and service commands never
|
||||
// re-derive identity semantics.
|
||||
func FilterForStrictMode(mode core.StrictMode) apicatalog.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := meta.TokenForIdentity(string(mode.ForcedIdentity()))
|
||||
return func(m meta.Method) bool { return m.SupportsToken(token) }
|
||||
}
|
||||
|
||||
// FilterScopes filters scopes by domain and permission level.
|
||||
@@ -76,71 +123,45 @@ func FilterScopes(allScopes []string, domains []string, permissions []string) []
|
||||
return result
|
||||
}
|
||||
|
||||
var cachedAllScopes map[string][]string
|
||||
|
||||
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
|
||||
// for the given identity ("user" or "tenant"). Results are deduplicated and sorted.
|
||||
func CollectAllScopesFromMeta(identity string) []string {
|
||||
if cachedAllScopes == nil {
|
||||
cachedAllScopes = make(map[string][]string)
|
||||
}
|
||||
if cached, ok := cachedAllScopes[identity]; ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
wantToken := meta.TokenForIdentity(identity)
|
||||
supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) }
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, ref := range RuntimeCatalog().WalkMethods(supported) {
|
||||
for _, s := range ref.Method.Scopes {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(scopeSet))
|
||||
for s := range scopeSet {
|
||||
result = append(result, s)
|
||||
}
|
||||
sort.Strings(result)
|
||||
cachedAllScopes[identity] = result
|
||||
return result
|
||||
}
|
||||
|
||||
// CollectScopesForProjects collects the recommended scope for each API method
|
||||
// in the specified from_meta projects. For each method, only the scope with
|
||||
// the highest priority score is selected.
|
||||
func CollectScopesForProjects(projects []string, identity string) []string {
|
||||
priorities := LoadScopePriorities()
|
||||
scopeSet := make(map[string]bool)
|
||||
for _, project := range projects {
|
||||
spec := LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
continue
|
||||
}
|
||||
resources, ok := spec["resources"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, resSpec := range resources {
|
||||
resMap, ok := resSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, ok := resMap["methods"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, methodSpec := range methods {
|
||||
methodMap, ok := methodSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
|
||||
supported := false
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
scopes, ok := methodMap["scopes"].([]interface{})
|
||||
if !ok || len(scopes) == 0 {
|
||||
continue
|
||||
}
|
||||
bestScope := ""
|
||||
bestScore := -1
|
||||
for _, s := range scopes {
|
||||
str, ok := s.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
score := DefaultScopeScore
|
||||
if v, exists := priorities[str]; exists {
|
||||
score = v
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestScope = str
|
||||
}
|
||||
}
|
||||
if bestScope != "" {
|
||||
scopeSet[bestScope] = true
|
||||
}
|
||||
}
|
||||
for _, ref := range methodsForProjects(projects, identity) {
|
||||
if best := bestScope(ref.Method.Scopes, priorities); best != "" {
|
||||
scopeSet[best] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,78 +186,25 @@ func CollectScopesWithSources(projects []string, identity string) ([]string, map
|
||||
scopeSet := make(map[string]bool)
|
||||
sources := make(map[string]*ScopeSource)
|
||||
|
||||
for _, project := range projects {
|
||||
spec := LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
for _, ref := range methodsForProjects(projects, identity) {
|
||||
m := ref.Method
|
||||
best := bestScope(m.Scopes, priorities)
|
||||
if best == "" {
|
||||
continue
|
||||
}
|
||||
resources, ok := spec["resources"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
scopeSet[best] = true
|
||||
if sources[best] == nil {
|
||||
sources[best] = &ScopeSource{}
|
||||
}
|
||||
for resName, resSpec := range resources {
|
||||
resMap, ok := resSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, ok := resMap["methods"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for methodName, methodSpec := range methods {
|
||||
methodMap, ok := methodSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
|
||||
supported := false
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
scopes, ok := methodMap["scopes"].([]interface{})
|
||||
if !ok || len(scopes) == 0 {
|
||||
continue
|
||||
}
|
||||
bestScope := ""
|
||||
bestScore := -1
|
||||
for _, s := range scopes {
|
||||
str, ok := s.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
score := DefaultScopeScore
|
||||
if v, exists := priorities[str]; exists {
|
||||
score = v
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestScope = str
|
||||
}
|
||||
}
|
||||
if bestScope != "" {
|
||||
scopeSet[bestScope] = true
|
||||
if sources[bestScope] == nil {
|
||||
sources[bestScope] = &ScopeSource{}
|
||||
}
|
||||
methodID := GetStrFromMap(methodMap, "id")
|
||||
if methodID == "" {
|
||||
methodID = project + "." + resName + "." + methodName
|
||||
}
|
||||
httpMethod := GetStrFromMap(methodMap, "httpMethod")
|
||||
if httpMethod == "" {
|
||||
httpMethod = "?"
|
||||
}
|
||||
sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID)
|
||||
}
|
||||
}
|
||||
methodID := m.ID
|
||||
if methodID == "" {
|
||||
methodID = ref.ServiceName() + "." + ref.ResourceName() + "." + ref.MethodName()
|
||||
}
|
||||
httpMethod := m.HTTPMethod
|
||||
if httpMethod == "" {
|
||||
httpMethod = "?"
|
||||
}
|
||||
sources[best].APIs = append(sources[best].APIs, httpMethod+" "+methodID)
|
||||
}
|
||||
|
||||
// Sort API lists for stable output
|
||||
@@ -267,92 +235,27 @@ type CommandEntry struct {
|
||||
// - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction).
|
||||
// - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege).
|
||||
func CollectCommandScopes(projects []string, identity string) []CommandEntry {
|
||||
priorities := LoadScopePriorities()
|
||||
var entries []CommandEntry
|
||||
|
||||
for _, project := range projects {
|
||||
spec := LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
for _, ref := range methodsForProjects(projects, identity) {
|
||||
m := ref.Method
|
||||
if len(m.Scopes) == 0 {
|
||||
continue
|
||||
}
|
||||
resources, ok := spec["resources"].(map[string]interface{})
|
||||
if !ok {
|
||||
|
||||
// Effective-scope policy (requiredScopes conjunction, else recommended)
|
||||
// lives once in DeclaredScopesForMethod.
|
||||
effectiveScopes := DeclaredScopesForMethod(m, identity)
|
||||
if len(effectiveScopes) == 0 {
|
||||
continue
|
||||
}
|
||||
for resName, resSpec := range resources {
|
||||
resMap, ok := resSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
methods, ok := resMap["methods"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for methodName, methodSpec := range methods {
|
||||
methodMap, ok := methodSpec.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tokens, ok := methodMap["accessTokens"].([]interface{}); ok {
|
||||
supported := false
|
||||
for _, t := range tokens {
|
||||
if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) {
|
||||
supported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
}
|
||||
rawScopes, ok := methodMap["scopes"].([]interface{})
|
||||
if !ok || len(rawScopes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for requiredScopes (conjunction — all needed)
|
||||
var effectiveScopes []string
|
||||
if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 {
|
||||
for _, s := range reqRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
effectiveScopes = append(effectiveScopes, str)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pick the single best scope (minimum privilege)
|
||||
bestScope := ""
|
||||
bestScore := -1
|
||||
for _, s := range rawScopes {
|
||||
str, ok := s.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
score := DefaultScopeScore
|
||||
if v, exists := priorities[str]; exists {
|
||||
score = v
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestScope = str
|
||||
}
|
||||
}
|
||||
if bestScope != "" {
|
||||
effectiveScopes = []string{bestScope}
|
||||
}
|
||||
}
|
||||
if len(effectiveScopes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
httpMethod := GetStrFromMap(methodMap, "httpMethod")
|
||||
entries = append(entries, CommandEntry{
|
||||
Command: resName + " " + methodName,
|
||||
Type: "api",
|
||||
Scopes: effectiveScopes,
|
||||
HTTPMethod: httpMethod,
|
||||
})
|
||||
}
|
||||
}
|
||||
entries = append(entries, CommandEntry{
|
||||
Command: ref.ResourceName() + " " + ref.MethodName(),
|
||||
Type: "api",
|
||||
Scopes: effectiveScopes,
|
||||
HTTPMethod: m.HTTPMethod,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||||
// We only assert key-set membership, not absolute order — the upstream
|
||||
// meta_data API does not guarantee a stable JSON key sequence across
|
||||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||||
}
|
||||
wantParams := map[string]bool{
|
||||
"message_id": true, "reaction_type": true, "page_token": true,
|
||||
"page_size": true, "user_id_type": true,
|
||||
}
|
||||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||||
}
|
||||
for _, k := range order.Parameters {
|
||||
if !wantParams[k] {
|
||||
t.Errorf("unexpected parameter key %q", k)
|
||||
}
|
||||
}
|
||||
// im.reactions.list 是 GET,没有 requestBody
|
||||
if len(order.RequestBody) != 0 {
|
||||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.images.create, got nil")
|
||||
}
|
||||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||||
}
|
||||
for _, k := range order.RequestBody {
|
||||
if !wantBody[k] {
|
||||
t.Errorf("unexpected requestBody key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||||
if order != nil {
|
||||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -313,16 +260,13 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||||
params.Properties.Order, mko.Parameters)
|
||||
if want := []string{"message_id", "page_size", "page_token", "reaction_type", "user_id_type"}; !reflect.DeepEqual(params.Properties.Order, want) {
|
||||
t.Errorf("params.properties order = %v, want %v (alphabetical)", params.Properties.Order, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -382,10 +326,8 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
is := buildInputSchema(meta.FromMap(method))
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
yes, ok := is.Properties.Map["yes"]
|
||||
@@ -413,9 +355,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
if _, ok := is.Properties.Map["yes"]; ok {
|
||||
@@ -425,9 +364,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
|
||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
os := buildOutputSchema(method)
|
||||
|
||||
@@ -450,31 +386,6 @@ func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAccessTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []interface{}
|
||||
want []string
|
||||
}{
|
||||
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
|
||||
{"user only", []interface{}{"user"}, []string{"user"}},
|
||||
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
|
||||
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
|
||||
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
|
||||
{"empty", []interface{}{}, []string{}},
|
||||
{"nil", nil, []string{}},
|
||||
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertAccessTokens(tt.input)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_FullFields(t *testing.T) {
|
||||
// Synthesized method to avoid runtime variance from remote-cache overlay
|
||||
// (which strips `risk` from merged services). All other field semantics
|
||||
@@ -489,7 +400,7 @@ func TestBuildMeta_FullFields(t *testing.T) {
|
||||
"accessTokens": []interface{}{"tenant"},
|
||||
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
|
||||
}
|
||||
m := buildMeta(method)
|
||||
m := buildMeta(meta.FromMap(method))
|
||||
|
||||
if m.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
|
||||
@@ -526,7 +437,7 @@ func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
|
||||
"accessTokens": []interface{}{"user"},
|
||||
// no risk field
|
||||
}
|
||||
m := buildMeta(method)
|
||||
m := buildMeta(meta.FromMap(method))
|
||||
if m.Risk != "read" {
|
||||
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
|
||||
}
|
||||
@@ -540,58 +451,26 @@ func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_NilOrEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw interface{}
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty object", map[string]interface{}{}},
|
||||
{"all-five-empty-arrays", map[string]interface{}{
|
||||
"use_when": []interface{}{},
|
||||
"do_not_use_when": []interface{}{},
|
||||
"prerequisites": []interface{}{},
|
||||
"examples": []interface{}{},
|
||||
"related": []interface{}{},
|
||||
}},
|
||||
{"malformed (string)", "not an object"},
|
||||
{"malformed (number)", 42},
|
||||
{"malformed (nested type mismatch)", map[string]interface{}{
|
||||
"examples": "should be a list, not a string",
|
||||
}},
|
||||
func TestConvert_EnumDescriptions(t *testing.T) {
|
||||
// options carrying descriptions -> enum + parallel enumDescriptions
|
||||
withDesc := Convert(meta.Field{Type: "string", Options: []meta.Option{
|
||||
{Value: "open_id", Description: "A"},
|
||||
{Value: "user_id", Description: "B"},
|
||||
}})
|
||||
if !reflect.DeepEqual(withDesc.Enum, []interface{}{"open_id", "user_id"}) {
|
||||
t.Errorf("Enum = %v", withDesc.Enum)
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseAffordance(c.raw); got != nil {
|
||||
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
|
||||
}
|
||||
})
|
||||
if !reflect.DeepEqual(withDesc.EnumDescriptions, []string{"A", "B"}) {
|
||||
t.Errorf("EnumDescriptions = %v, want [A B] aligned with enum", withDesc.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_FullPopulated(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
|
||||
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
|
||||
"prerequisites": []interface{}{"user 身份登录"},
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
|
||||
},
|
||||
"related": []interface{}{"calendars.list"},
|
||||
// bare enum form (no descriptions) -> enumDescriptions omitted (nil)
|
||||
bare := Convert(meta.Field{Type: "string", Enum: []any{"x", "y"}})
|
||||
if !reflect.DeepEqual(bare.Enum, []interface{}{"x", "y"}) {
|
||||
t.Errorf("bare Enum = %v", bare.Enum)
|
||||
}
|
||||
a := parseAffordance(raw)
|
||||
if a == nil {
|
||||
t.Fatal("parseAffordance returned nil, want populated")
|
||||
}
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
|
||||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
if bare.EnumDescriptions != nil {
|
||||
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,7 +483,7 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
"use_when": []interface{}{"trigger"},
|
||||
},
|
||||
}
|
||||
m := buildMeta(method)
|
||||
m := buildMeta(meta.FromMap(method))
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("Affordance should be populated from method[\"affordance\"]")
|
||||
}
|
||||
@@ -620,7 +499,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
"risk": "read",
|
||||
// no docUrl
|
||||
}
|
||||
m := buildMeta(method)
|
||||
m := buildMeta(meta.FromMap(method))
|
||||
if m.DocURL != "" {
|
||||
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
|
||||
}
|
||||
@@ -634,8 +513,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||
method := map[string]interface{}{}
|
||||
currentMethodOrder = nil
|
||||
os := buildOutputSchema(method)
|
||||
os := buildOutputSchema(meta.FromMap(method))
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
}
|
||||
@@ -647,9 +525,16 @@ func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// synthEnvelope renders an envelope for a synthetic (service, resourcePath, method)
|
||||
// via the public ref entry, so these unit tests build the same MethodRef the
|
||||
// command layer feeds Envelope.
|
||||
func synthEnvelope(serviceName string, resourcePath []string, m meta.Method) Envelope {
|
||||
return EnvelopeOf(apicatalog.MethodRef{Service: meta.Service{Name: serviceName}, ResourcePath: resourcePath, Method: m})
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
env := synthEnvelope("im", []string{"reactions"}, method)
|
||||
|
||||
if env.Name != "im reactions list" {
|
||||
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
|
||||
@@ -671,7 +556,7 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
|
||||
// overlay strips `bots` from the loaded method map on this environment;
|
||||
// the assertion is about name joining, not method specifics.
|
||||
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
|
||||
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
|
||||
env := synthEnvelope("im", []string{"chat.members"}, method)
|
||||
// chat.members resourcePath stays as one element in the slice with a dot;
|
||||
// name should split it to "im chat.members create" — we keep the dot as-is
|
||||
// inside the resource segment to round-trip with completion logic.
|
||||
@@ -683,8 +568,8 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
|
||||
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
|
||||
// Assemble twice; JSON output must be byte-identical (determinism).
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
a := synthEnvelope("im", []string{"reactions"}, method)
|
||||
b := synthEnvelope("im", []string{"reactions"}, method)
|
||||
ja, _ := json.MarshalIndent(a, "", " ")
|
||||
jb, _ := json.MarshalIndent(b, "", " ")
|
||||
if string(ja) != string(jb) {
|
||||
@@ -693,8 +578,8 @@ func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAssembleService_Im(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
envs := AssembleService("im", spec, nil)
|
||||
svc, _ := registry.ServiceTyped("im")
|
||||
envs := Envelopes(apicatalog.ServiceMethods(svc, nil))
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty envelopes for service im")
|
||||
}
|
||||
@@ -713,17 +598,16 @@ func TestAssembleService_Im(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAssembleService_FilterByAccessToken(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
svc, _ := registry.ServiceTyped("im")
|
||||
// Filter to bot-only (--as bot, which corresponds to "tenant")
|
||||
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == "tenant" {
|
||||
envs := Envelopes(apicatalog.ServiceMethods(svc, func(m meta.Method) bool {
|
||||
for _, t := range m.AccessTokens {
|
||||
if t == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}))
|
||||
// Every envelope's _meta.access_tokens must contain "bot"
|
||||
for _, e := range envs {
|
||||
found := false
|
||||
@@ -740,11 +624,11 @@ func TestAssembleService_FilterByAccessToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAssembleAll_AtLeast193(t *testing.T) {
|
||||
envs := AssembleAll(nil)
|
||||
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
|
||||
// embedded meta_data.json directly, so the count is stable across machines.
|
||||
envs := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil))
|
||||
// Envelope assembly is overlay-independent: it walks the embedded
|
||||
// meta_data.json directly, so the count is stable across machines.
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
|
||||
t.Errorf("envelope count = %d, expected >= 193", len(envs))
|
||||
}
|
||||
// Spot check: im reactions list should be present
|
||||
found := false
|
||||
@@ -759,24 +643,32 @@ func TestAssembleAll_AtLeast193(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
|
||||
// real embedded meta_data.json via the registry package.
|
||||
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
|
||||
// loadMethodFromRegistry is a test helper that pulls one method from the real
|
||||
// embedded meta_data.json via the registry's typed accessor, with Name set.
|
||||
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) meta.Method {
|
||||
t.Helper()
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
svc, ok := registry.ServiceTyped(service)
|
||||
if !ok {
|
||||
t.Fatalf("service %q not found in registry", service)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resKey := strings.Join(resourcePath, ".")
|
||||
res, ok := resources[resKey].(map[string]interface{})
|
||||
res, ok := svc.Resources[resKey]
|
||||
if !ok {
|
||||
t.Fatalf("resource %q.%s not found", service, resKey)
|
||||
}
|
||||
methods, _ := res["methods"].(map[string]interface{})
|
||||
m, ok := methods[methodName].(map[string]interface{})
|
||||
m, ok := res.Methods[methodName]
|
||||
if !ok {
|
||||
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
|
||||
}
|
||||
m.Name = methodName
|
||||
return m
|
||||
}
|
||||
|
||||
// convertProperty is a test helper: it decodes a single field-spec map into a
|
||||
// meta.Field and renders its Property (the conversion the assembler does).
|
||||
func convertProperty(fieldMap map[string]interface{}, _ string) Property {
|
||||
b, _ := json.Marshal(fieldMap)
|
||||
var f meta.Field
|
||||
_ = json.Unmarshal(b, &f)
|
||||
return Convert(f)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
var validJSONSchemaTypes = map[string]bool{
|
||||
@@ -81,7 +81,7 @@ func lintEnvelope(env Envelope) []error {
|
||||
}
|
||||
|
||||
// ---- L3: cross-field self-consistency ----
|
||||
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
dangerExpected := env.Meta.Risk == core.RiskWrite || env.Meta.Risk == core.RiskHighRiskWrite
|
||||
if env.Meta.Danger != dangerExpected {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func lintEnvelope(env Envelope) []error {
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
_, hasYes = env.InputSchema.Properties.Map["yes"]
|
||||
}
|
||||
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
wantYes := env.Meta.Risk == core.RiskHighRiskWrite
|
||||
if hasYes != wantYes {
|
||||
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
|
||||
}
|
||||
@@ -125,6 +125,9 @@ func walkForL2(props *OrderedProps, errs *[]error) {
|
||||
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
|
||||
}
|
||||
if n := len(p.EnumDescriptions); n > 0 && n != len(p.Enum) {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q enumDescriptions length (%d) != enum length (%d)", k, n, len(p.Enum)))
|
||||
}
|
||||
if len(p.Required) > 0 && p.Properties != nil {
|
||||
for _, r := range p.Required {
|
||||
if _, ok := p.Properties.Map[r]; !ok {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -147,6 +148,18 @@ func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
|
||||
},
|
||||
wantSub: "minimum",
|
||||
},
|
||||
{
|
||||
name: "enumDescriptions length must match enum",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"k"}
|
||||
e.InputSchema.Properties.Map["k"] = Property{
|
||||
Type: "string",
|
||||
Enum: []interface{}{"a", "b", "c"},
|
||||
EnumDescriptions: []string{"only one"}, // misaligned with 3 enum values
|
||||
}
|
||||
},
|
||||
wantSub: "enumDescriptions",
|
||||
},
|
||||
{
|
||||
// Regression guard: walkForL2 must recurse into the params/data
|
||||
// sub-objects introduced by the 4-bucket inputSchema, not only the
|
||||
@@ -334,9 +347,8 @@ func TestAllEnvelopesPass(t *testing.T) {
|
||||
knownEnvelopes := map[string]bool{}
|
||||
// Use embedded data only so the gate is deterministic across machines
|
||||
// (matches Task 17b: envelope assembly is overlay-independent).
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
envs := AssembleService(svc, spec, nil)
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
envs := Envelopes(apicatalog.ServiceMethods(svc, nil))
|
||||
for _, env := range envs {
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
@@ -366,7 +378,7 @@ func TestAllEnvelopesPass(t *testing.T) {
|
||||
}
|
||||
|
||||
// L4 coverage report (warn-only via t.Logf)
|
||||
all := AssembleAll(nil)
|
||||
all := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil))
|
||||
c := measureCoverage(all)
|
||||
for metric, rate := range c {
|
||||
baseline := coverageBaseline[metric]
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
|
||||
// slice of path segments. It accepts two equivalent forms:
|
||||
//
|
||||
// lark-cli schema im.messages.reply -> single arg, split on "."
|
||||
// lark-cli schema im messages reply -> multiple args, used as-is
|
||||
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
|
||||
// arguments individually if your shell needs it. Nested resources keep their
|
||||
// internal dots (e.g. "chat.members").
|
||||
//
|
||||
// Returns nil for zero args (bare invocation).
|
||||
func ParsePath(args []string) []string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if strings.Contains(args[0], ".") {
|
||||
return strings.Split(args[0], ".")
|
||||
}
|
||||
return []string{args[0]}
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||
@@ -45,42 +47,32 @@ type Property struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Example interface{} `json:"example,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
|
||||
// each allowed value, in the same order. Omitted when no value carries a
|
||||
// description. This is the widely-recognized JSON-Schema extension (VS Code,
|
||||
// OpenAPI tooling) that lets an AI consumer learn what each enum value means
|
||||
// without a second lookup.
|
||||
EnumDescriptions []string `json:"enumDescriptions,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Example interface{} `json:"example,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the Lark-specific extension namespace.
|
||||
type Meta struct {
|
||||
EnvelopeVersion string `json:"envelope_version"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RequiredScopes []string `json:"required_scopes"`
|
||||
AccessTokens []string `json:"access_tokens"`
|
||||
Danger bool `json:"danger"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
Affordance *Affordance `json:"affordance,omitempty"`
|
||||
}
|
||||
|
||||
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one example entry: a one-line description plus a
|
||||
// ready-to-run lark-cli command string.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
EnvelopeVersion string `json:"envelope_version"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RequiredScopes []string `json:"required_scopes"`
|
||||
AccessTokens []string `json:"access_tokens"`
|
||||
Danger bool `json:"danger"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
Affordance *meta.Affordance `json:"affordance,omitempty"`
|
||||
}
|
||||
|
||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
||||
@@ -91,6 +83,20 @@ type OrderedProps struct {
|
||||
Map map[string]Property
|
||||
}
|
||||
|
||||
// Set adds or replaces a property, recording first-seen keys in Order so JSON
|
||||
// output preserves insertion order. Re-setting an existing key updates its
|
||||
// value without reordering. Centralizing mutation here keeps Order and Map from
|
||||
// drifting out of sync.
|
||||
func (o *OrderedProps) Set(key string, p Property) {
|
||||
if o.Map == nil {
|
||||
o.Map = make(map[string]Property)
|
||||
}
|
||||
if _, exists := o.Map[key]; !exists {
|
||||
o.Order = append(o.Order, key)
|
||||
}
|
||||
o.Map[key] = p
|
||||
}
|
||||
|
||||
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
|
||||
// Map has entries, fall back to alphabetical key order over Map so callers
|
||||
// that only populated Map (no explicit ordering) still see their fields.
|
||||
|
||||
@@ -8,6 +8,21 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrderedProps_Set(t *testing.T) {
|
||||
op := &OrderedProps{}
|
||||
op.Set("b", Property{Type: "string"})
|
||||
op.Set("a", Property{Type: "integer"})
|
||||
op.Set("b", Property{Type: "boolean"}) // re-set: updates value, keeps position
|
||||
|
||||
wantOrder := []string{"b", "a"}
|
||||
if len(op.Order) != len(wantOrder) || op.Order[0] != "b" || op.Order[1] != "a" {
|
||||
t.Errorf("Order = %v, want %v (insertion order, no duplicate on re-set)", op.Order, wantOrder)
|
||||
}
|
||||
if op.Map["b"].Type != "boolean" {
|
||||
t.Errorf("re-set value = %q, want boolean", op.Map["b"].Type)
|
||||
}
|
||||
}
|
||||
|
||||
// OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。
|
||||
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
|
||||
op := &OrderedProps{
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Proxy environment constants control shared transport proxy behavior.
|
||||
@@ -79,16 +77,8 @@ func WarnIfProxied(w io.Writer) {
|
||||
// Shared), so its warning and disable instructions take precedence.
|
||||
// Emitting the env-proxy warning here would be misleading: it tells the
|
||||
// user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy.
|
||||
if addr, caPath, enabled := proxyPluginStatus(); enabled {
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
|
||||
redactProxyURL(addr), envvars.CliProxyEnable, Path())
|
||||
if strings.TrimSpace(caPath) != "" {
|
||||
// A custom CA means upstream TLS can be intercepted/inspected by
|
||||
// the proxy (MITM). Surface it so the operator is aware traffic
|
||||
// (including Bearer tokens) is decryptable on this host.
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
|
||||
caPath)
|
||||
}
|
||||
if _, _, enabled := proxyPluginStatus(); enabled {
|
||||
fmt.Fprintln(w, "[lark-cli] [WARN] proxy plugin enabled: all requests are forced through proxy.")
|
||||
return
|
||||
}
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
||||
@@ -120,9 +118,9 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is
|
||||
// enabled, the warning describes the plugin proxy and the correct disable method
|
||||
// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY
|
||||
// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set.
|
||||
// enabled, the warning is a single concise line that does not leak the proxy
|
||||
// address or give the misleading LARK_CLI_NO_PROXY disable instruction — even
|
||||
// when env proxy and LARK_CLI_NO_PROXY are also set.
|
||||
func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
@@ -140,51 +138,24 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "127.0.0.1:3128") {
|
||||
t.Errorf("warning should mention the plugin proxy address, got: %s", out)
|
||||
if !strings.Contains(out, "proxy plugin enabled") {
|
||||
t.Errorf("warning should announce proxy plugin enabled, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, envvars.CliProxyEnable) {
|
||||
t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out)
|
||||
// Single line only — no address, CA, or disable hints.
|
||||
if strings.Count(out, "\n") != 1 {
|
||||
t.Errorf("warning must be a single line, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1:3128") || strings.Contains(out, "corp-proxy") {
|
||||
t.Errorf("warning must not leak the proxy address, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "Set "+EnvNoProxy+"=1") {
|
||||
t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out)
|
||||
}
|
||||
// No custom CA configured -> no interception warning.
|
||||
if strings.Contains(out, "custom CA") {
|
||||
t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
|
||||
// trusted, the warning surfaces the TLS-interception capability.
|
||||
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
old := proxyPluginStatus
|
||||
proxyPluginStatus = func() (string, string, bool) {
|
||||
return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true
|
||||
}
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "custom CA") {
|
||||
t.Errorf("warning should mention the custom CA, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/etc/lark/extra_ca.pem") {
|
||||
t.Errorf("warning should include the CA path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "intercept") {
|
||||
t.Errorf("warning should mention TLS interception, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
|
||||
// warning never leaks credentials embedded in the configured proxy address.
|
||||
// warning never leaks credentials embedded in the configured proxy address —
|
||||
// the simplified message omits the address entirely.
|
||||
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
@@ -204,9 +175,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
if strings.Contains(out, "user:") {
|
||||
t.Errorf("plugin warning leaked username, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "***@127.0.0.1:3128") {
|
||||
t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||
|
||||
@@ -60,12 +60,12 @@ func safePath(raw, flagName string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
@@ -114,6 +114,21 @@ func resolveNearestAncestor(path string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func isAbsolutePath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
drive := path[0]
|
||||
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,6 +34,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
// ── GIVEN: absolute paths → THEN: rejected ──
|
||||
{"absolute path unix", "/etc/passwd", true},
|
||||
{"absolute path root", "/tmp/evil", true},
|
||||
{"absolute path windows drive", `C:\Users\agent\secret.txt`, true},
|
||||
{"absolute path windows drive slash", "C:/Users/agent/secret.txt", true},
|
||||
{"absolute path windows rooted", `\Users\agent\secret.txt`, true},
|
||||
{"absolute path windows unc", `\\server\share\secret.txt`, true},
|
||||
|
||||
// ── GIVEN: control characters in path → THEN: rejected ──
|
||||
{"null byte", "file\x00.txt", true},
|
||||
@@ -187,11 +191,23 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
|
||||
// GIVEN: an absolute path outside the temp directory
|
||||
// WHEN / THEN: SafeUploadPath rejects it
|
||||
_, err := SafeInputPath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute non-temp path, got nil")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"absolute path unix", "/etc/passwd"},
|
||||
{"absolute path windows drive", `C:\Users\agent\secret.txt`},
|
||||
{"absolute path windows drive slash", "C:/Users/agent/secret.txt"},
|
||||
{"absolute path windows rooted", `\Users\agent\secret.txt`},
|
||||
{"absolute path windows unc", `\\server\share\secret.txt`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// WHEN / THEN: SafeInputPath rejects absolute paths on every platform.
|
||||
_, err := SafeInputPath(tt.input)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for absolute path %q, got nil", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,230 +39,296 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -488,6 +488,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -34,128 +34,160 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
|
||||
@@ -150,6 +150,12 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -25,6 +25,119 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-list": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "cursor",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Pagination cursor; pass the previous page's next_cursor, omit for first page"
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "History versions per page, default 20"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "revision-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Restore the whole spreadsheet to this version: a revision_id (minor id) from +history-list"
|
||||
},
|
||||
{
|
||||
"name": "edit-time",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "The matching edit_time from the same +history-list entry; pass it to locate the version faster"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "task-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Revert task id returned by +history-revert"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -54,7 +167,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Insert position; appended to the end when omitted",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -413,6 +526,86 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -431,27 +624,45 @@
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -513,6 +724,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-import": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "file",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Local file path (.xlsx / .xls / .csv)"
|
||||
},
|
||||
{
|
||||
"name": "folder-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; imported to the cloud drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -1212,19 +1449,72 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "rows-json",
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by id); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by name); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet current region"
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
},
|
||||
{
|
||||
"name": "dataframe-out",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1849,7 +2139,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1880,6 +2170,61 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token to write into (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -462,8 +462,21 @@
|
||||
"type": "string"
|
||||
},
|
||||
"mention_type": {
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)",
|
||||
"type": "number"
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取:1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
|
||||
"type": "number",
|
||||
"enum": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
8,
|
||||
11,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
30,
|
||||
38
|
||||
]
|
||||
},
|
||||
"notify": {
|
||||
"description": "是否发送通知(仅 type='mention' 时可选,默认 true)",
|
||||
@@ -1730,11 +1743,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1787,11 +1801,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2769,11 +2779,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2826,11 +2837,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6249,6 +6256,744 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,14 +365,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -382,8 +385,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -393,14 +396,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -421,8 +423,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
@@ -308,7 +308,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
@@ -320,7 +319,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -633,6 +632,35 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "cursor", Kind: "own", Type: "string", Required: "optional", Desc: "Pagination cursor; pass the previous page's next_cursor, omit for first page"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "History versions per page, default 20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "revision-id", Kind: "own", Type: "string", Required: "required", Desc: "Restore the whole spreadsheet to this version: a revision_id (minor id) from +history-list"},
|
||||
{Name: "edit-time", Kind: "own", Type: "string", Required: "optional", Desc: "The matching edit_time from the same +history-list entry; pass it to locate the version faster"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+history-revert-status": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "task-id", Kind: "own", Type: "string", Required: "required", Desc: "Revert task id returned by +history-revert"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -766,7 +794,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -793,6 +821,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -839,6 +877,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -895,13 +943,39 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -916,6 +990,14 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
|
||||
@@ -63,6 +63,7 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
"styles": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
|
||||
@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
553
shortcuts/sheets/lark_sheet_dataframe.go
Normal file
553
shortcuts/sheets/lark_sheet_dataframe.go
Normal file
@@ -0,0 +1,553 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
|
||||
//
|
||||
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
|
||||
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
|
||||
// writes), single schema, optionally multi-batch. Type / format are read off
|
||||
// the Arrow schema (no separate dtypes/formats maps), and per-column number
|
||||
// format can be set via the field's `number_format` metadata key:
|
||||
//
|
||||
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
|
||||
//
|
||||
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
|
||||
// (adopted in place by +workbook-create; created when absent by +table-put),
|
||||
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
|
||||
// surface is deliberately the one flag — anything that needs a different
|
||||
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
|
||||
// JSON payload already carries every knob.
|
||||
//
|
||||
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
|
||||
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
|
||||
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
|
||||
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
|
||||
// stream byte-exact. "-" reads from stdin directly.
|
||||
|
||||
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
|
||||
// Matches valuesSheetName so +workbook-create adopts the brand-new
|
||||
// workbook's default sheet in place (no stray empty Sheet1 left behind);
|
||||
// +table-put creates Sheet1 if it doesn't already exist.
|
||||
const dataframeDefaultSheetName = valuesSheetName
|
||||
|
||||
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
|
||||
// composes a single-sheet tablePayload at the fixed default placement.
|
||||
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
|
||||
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
|
||||
// so downstream is unaware of where the rows came from.
|
||||
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("dataframe"))
|
||||
if raw == "" {
|
||||
return nil, common.FlagErrorf("--dataframe is required")
|
||||
}
|
||||
data, err := readDataframeBytes(rctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--dataframe: %v", err)
|
||||
}
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
|
||||
if err := payload.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// dataframeStdinCache holds the bytes read from stdin on the first call so a
|
||||
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
|
||||
// empty stream — stdin is single-shot, but parseDataframePayload runs
|
||||
// multiple times per command invocation. Process-wide is fine: lark-cli is
|
||||
// one-shot (one command per process). Tests reset by setting it back to nil.
|
||||
var dataframeStdinCache []byte
|
||||
|
||||
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
|
||||
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
|
||||
// both work). `-` reads stdin verbatim — cached on first call so Validate /
|
||||
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
|
||||
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
|
||||
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
|
||||
if raw == "-" {
|
||||
if dataframeStdinCache != nil {
|
||||
return dataframeStdinCache, nil
|
||||
}
|
||||
io := rctx.IO()
|
||||
if io == nil || io.In == nil {
|
||||
return nil, common.FlagErrorf("--dataframe: stdin is not available")
|
||||
}
|
||||
data, err := readAllBytes(io.In)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--dataframe: read stdin: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.FlagErrorf("--dataframe: stdin is empty")
|
||||
}
|
||||
dataframeStdinCache = data
|
||||
return data, nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--dataframe: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.FlagErrorf("--dataframe: file %q is empty", path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readAllBytes is a thin wrapper so tests can fake the io.Reader without
|
||||
// importing io. Mirrors io.ReadAll exactly.
|
||||
func readAllBytes(r io.Reader) ([]byte, error) { return io.ReadAll(r) }
|
||||
|
||||
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
|
||||
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
|
||||
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
|
||||
// not touched here — parseDataframePayload layers those on from CLI flags.
|
||||
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
|
||||
reader, err := ipc.NewFileReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
schema := reader.Schema()
|
||||
if schema == nil || schema.NumFields() == 0 {
|
||||
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields")
|
||||
}
|
||||
|
||||
ncols := schema.NumFields()
|
||||
cols := make([]tableColumnSpec, ncols)
|
||||
seen := make(map[string]bool, ncols)
|
||||
for i := 0; i < ncols; i++ {
|
||||
f := schema.Field(i)
|
||||
name := f.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i)
|
||||
}
|
||||
if seen[name] {
|
||||
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
typ, format, err := arrowFieldToTypeFormat(f)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %q: %v", name, err)
|
||||
}
|
||||
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
|
||||
}
|
||||
|
||||
var rows [][]interface{}
|
||||
for b := 0; b < reader.NumRecords(); b++ {
|
||||
rec, err := reader.RecordAt(b)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %v", b, err)
|
||||
}
|
||||
batchRows, err := arrowRecordToRows(rec, cols)
|
||||
rec.Release()
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, err
|
||||
}
|
||||
rows = append(rows, batchRows...)
|
||||
}
|
||||
|
||||
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
|
||||
}
|
||||
|
||||
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
|
||||
// pair. The field's `number_format` metadata key — when present — sets the
|
||||
// Excel number_format string verbatim; otherwise sensible defaults are
|
||||
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
|
||||
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
|
||||
if v, ok := f.Metadata.GetValue("number_format"); ok {
|
||||
format = strings.TrimSpace(v)
|
||||
}
|
||||
switch f.Type.(type) {
|
||||
case *arrow.StringType, *arrow.LargeStringType:
|
||||
if format == "" {
|
||||
format = "@"
|
||||
}
|
||||
return "string", format, nil
|
||||
case *arrow.BooleanType:
|
||||
return "bool", format, nil
|
||||
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
|
||||
if format == "" {
|
||||
format = "yyyy-mm-dd"
|
||||
}
|
||||
return "date", format, nil
|
||||
}
|
||||
if isArrowNumericType(f.Type) {
|
||||
return "number", format, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name())
|
||||
}
|
||||
|
||||
func isArrowNumericType(t arrow.DataType) bool {
|
||||
switch t.ID() {
|
||||
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
|
||||
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
|
||||
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// arrowRecordToRows transposes one column-batch into row-major
|
||||
// [][]interface{} matched to `cols`. Cells are stamped with the same value
|
||||
// shapes buildTypedCell expects from the JSON path: nil for nulls,
|
||||
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
|
||||
// dates/timestamps, bool for booleans, string for strings.
|
||||
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
|
||||
if int(rec.NumCols()) != len(cols) {
|
||||
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols))
|
||||
}
|
||||
nrows := int(rec.NumRows())
|
||||
rows := make([][]interface{}, nrows)
|
||||
for r := range rows {
|
||||
rows[r] = make([]interface{}, len(cols))
|
||||
}
|
||||
for c := range cols {
|
||||
arr := rec.Column(c)
|
||||
for r := 0; r < nrows; r++ {
|
||||
if arr.IsNull(r) {
|
||||
continue
|
||||
}
|
||||
v, err := arrowCellValue(arr, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %v", r, cols[c].Name, err)
|
||||
}
|
||||
rows[r][c] = v
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
|
||||
switch a := arr.(type) {
|
||||
case *array.String:
|
||||
return a.Value(i), nil
|
||||
case *array.LargeString:
|
||||
return a.Value(i), nil
|
||||
case *array.Boolean:
|
||||
return a.Value(i), nil
|
||||
case *array.Int8:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int16:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int32:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int64:
|
||||
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
|
||||
case *array.Uint8:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint16:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint32:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint64:
|
||||
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
|
||||
case *array.Float16:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
|
||||
case *array.Float32:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
|
||||
case *array.Float64:
|
||||
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
|
||||
case *array.Date32:
|
||||
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
|
||||
// in UTC so timezone offset can't flip the calendar date.
|
||||
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Date64:
|
||||
t := time.UnixMilli(int64(a.Value(i))).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Timestamp:
|
||||
ts := int64(a.Value(i))
|
||||
unit := a.DataType().(*arrow.TimestampType).Unit
|
||||
var t time.Time
|
||||
switch unit {
|
||||
case arrow.Second:
|
||||
t = time.Unix(ts, 0).UTC()
|
||||
case arrow.Millisecond:
|
||||
t = time.UnixMilli(ts).UTC()
|
||||
case arrow.Microsecond:
|
||||
t = time.UnixMicro(ts).UTC()
|
||||
case arrow.Nanosecond:
|
||||
t = time.Unix(0, ts).UTC()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported timestamp unit %v", unit)
|
||||
}
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported Arrow array %T", arr)
|
||||
}
|
||||
|
||||
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
|
||||
//
|
||||
// +table-get's binary read-back: encode one sheet's typed read-back as an
|
||||
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
|
||||
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
|
||||
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
|
||||
// is unchanged; --dataframe-out swaps the encoder for callers that already
|
||||
// have pandas / pyarrow in their pipeline.
|
||||
|
||||
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
|
||||
// file blob. Internal column types are recovered from `dtypes` (the wire
|
||||
// proxy for the typed protocol), and per-column `number_format` rides through
|
||||
// as Arrow field metadata so the file feeds straight back into
|
||||
// `+table-put --dataframe`.
|
||||
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
|
||||
columns, _ := sheet["columns"].([]interface{})
|
||||
if len(columns) == 0 {
|
||||
return nil, fmt.Errorf("sheet has no columns")
|
||||
}
|
||||
dtypes, _ := sheet["dtypes"].(map[string]interface{})
|
||||
formats, _ := sheet["formats"].(map[string]interface{})
|
||||
// `data` arrives as either []interface{} (when the sheet came through a
|
||||
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
|
||||
// readSheetAsSpec directly emits in production). Accept both — anything
|
||||
// else falls through to a zero-row table.
|
||||
var rawData [][]interface{}
|
||||
switch d := sheet["data"].(type) {
|
||||
case [][]interface{}:
|
||||
rawData = d
|
||||
case []interface{}:
|
||||
rawData = make([][]interface{}, len(d))
|
||||
for i, r := range d {
|
||||
rawData[i], _ = r.([]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
ncols := len(columns)
|
||||
colNames := make([]string, ncols)
|
||||
colTypes := make([]string, ncols)
|
||||
fields := make([]arrow.Field, ncols)
|
||||
for i, c := range columns {
|
||||
name, _ := c.(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("column %d has empty name", i)
|
||||
}
|
||||
colNames[i] = name
|
||||
dt, _ := dtypes[name].(string)
|
||||
colTypes[i] = dtypeToInternalType(dt)
|
||||
var meta arrow.Metadata
|
||||
if formats != nil {
|
||||
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
|
||||
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
|
||||
}
|
||||
}
|
||||
fields[i] = arrow.Field{
|
||||
Name: name,
|
||||
Type: internalTypeToArrowType(colTypes[i]),
|
||||
Nullable: true,
|
||||
Metadata: meta,
|
||||
}
|
||||
}
|
||||
schema := arrow.NewSchema(fields, nil)
|
||||
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
for r, row := range rawData {
|
||||
if len(row) != ncols {
|
||||
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols)
|
||||
}
|
||||
for c := 0; c < ncols; c++ {
|
||||
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %v", r, colNames[c], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
var buf bytesWriterSeeker
|
||||
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc.NewFileWriter: %v", err)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
return nil, fmt.Errorf("write record: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close writer: %v", err)
|
||||
}
|
||||
return buf.buf, nil
|
||||
}
|
||||
|
||||
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
|
||||
// internal column type from the wire-level dtype string. Unknown / object
|
||||
// falls back to string (lossless: every cell is already typed as such).
|
||||
func dtypeToInternalType(dtype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(dtype)) {
|
||||
case "float64", "float32", "int64", "int32", "int16", "int8",
|
||||
"uint64", "uint32", "uint16", "uint8":
|
||||
return "number"
|
||||
case "bool", "boolean":
|
||||
return "bool"
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
|
||||
return "date"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
|
||||
// internal column type to the Arrow data type the encoder builds a column
|
||||
// with. Numbers go to float64 because +table-get can't tell int from float
|
||||
// from a number_format alone — float64 covers both losslessly for the cell
|
||||
// ranges Lark Sheets accepts.
|
||||
func internalTypeToArrowType(typ string) arrow.DataType {
|
||||
switch typ {
|
||||
case "number":
|
||||
return arrow.PrimitiveTypes.Float64
|
||||
case "date":
|
||||
return arrow.FixedWidthTypes.Date32
|
||||
case "bool":
|
||||
return arrow.FixedWidthTypes.Boolean
|
||||
}
|
||||
return arrow.BinaryTypes.String
|
||||
}
|
||||
|
||||
// appendArrowCell stamps one cell into its column builder. Cell shape matches
|
||||
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
|
||||
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
|
||||
// nil for empty. Anything off-shape errors so the caller doesn't silently
|
||||
// emit nulls for malformed data.
|
||||
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
|
||||
if v == nil {
|
||||
b.AppendNull()
|
||||
return nil
|
||||
}
|
||||
switch typ {
|
||||
case "string":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("string expects string value, got %T", v)
|
||||
}
|
||||
b.(*array.StringBuilder).Append(s)
|
||||
case "number":
|
||||
f, err := arrowNumber(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.(*array.Float64Builder).Append(f)
|
||||
case "date":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v)
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("date parse %q: %v", s, err)
|
||||
}
|
||||
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
|
||||
case "bool":
|
||||
bb, ok := v.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("bool expects bool, got %T", v)
|
||||
}
|
||||
b.(*array.BooleanBuilder).Append(bb)
|
||||
default:
|
||||
return fmt.Errorf("unsupported internal type %q", typ)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// arrowNumber converts the number cell shape readSheetAsSpec emits
|
||||
// (json.Number) plus the float fallback to float64 for the Arrow builder.
|
||||
func arrowNumber(v interface{}) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("number parse %q: %v", n.String(), err)
|
||||
}
|
||||
return f, nil
|
||||
case float64:
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("number expects numeric value, got %T", v)
|
||||
}
|
||||
|
||||
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
|
||||
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
|
||||
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
|
||||
// --dataframe-out's stdout path zero-IO and stays straightforward.
|
||||
type bytesWriterSeeker struct {
|
||||
buf []byte
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
|
||||
end := w.pos + int64(len(p))
|
||||
if end > int64(len(w.buf)) {
|
||||
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
|
||||
}
|
||||
n := copy(w.buf[w.pos:], p)
|
||||
w.pos = end
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
w.pos = offset
|
||||
case io.SeekCurrent:
|
||||
w.pos += offset
|
||||
case io.SeekEnd:
|
||||
w.pos = int64(len(w.buf)) + offset
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown whence %d", whence)
|
||||
}
|
||||
return w.pos, nil
|
||||
}
|
||||
|
||||
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
|
||||
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
|
||||
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
|
||||
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
|
||||
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
|
||||
if raw == "-" {
|
||||
out := rctx.IO()
|
||||
if out == nil || out.Out == nil {
|
||||
return common.FlagErrorf("--dataframe-out: stdout is not available")
|
||||
}
|
||||
if _, err := out.Out.Write(data); err != nil {
|
||||
return fmt.Errorf("--dataframe-out: write stdout: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
fio := rctx.FileIO()
|
||||
if fio == nil {
|
||||
return common.FlagErrorf("--dataframe-out: file output is not available in this context")
|
||||
}
|
||||
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
|
||||
// readDataframeBytes hits on the input side) and writes atomically, so we
|
||||
// don't need an extra ValidatePath call here.
|
||||
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
|
||||
return fmt.Errorf("--dataframe-out: write %q: %v", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
378
shortcuts/sheets/lark_sheet_dataframe_test.go
Normal file
378
shortcuts/sheets/lark_sheet_dataframe_test.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
)
|
||||
|
||||
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
|
||||
// Used by the round-trip tests below to stand in for what
|
||||
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
|
||||
// depending on a pandas-shaped fixture file.
|
||||
//
|
||||
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
|
||||
// offset), so we write to a temp file and read the bytes back — simpler than
|
||||
// re-implementing a seekable in-memory buffer.
|
||||
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
|
||||
t.Helper()
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
build(rb)
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "df.arrow")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp arrow file: %v", err)
|
||||
}
|
||||
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("ipc.NewFileWriter: %v", err)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read temp arrow file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
|
||||
// (type, format) mapping and the per-cell value shape that buildTypedCell
|
||||
// expects: number cells are json.Number (precision-preserving), date cells
|
||||
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
|
||||
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
|
||||
// quietly regress any one family.
|
||||
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "name", Type: arrow.BinaryTypes.String},
|
||||
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
|
||||
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
|
||||
[]string{"number_format"}, []string{"$#,##0.00"},
|
||||
)},
|
||||
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
|
||||
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
|
||||
}, nil)
|
||||
|
||||
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
|
||||
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
|
||||
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
|
||||
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
|
||||
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
|
||||
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
|
||||
})
|
||||
|
||||
spec, err := decodeArrowToSheet(buf, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
if spec.Name != "S1" {
|
||||
t.Errorf("sheet name = %q, want S1", spec.Name)
|
||||
}
|
||||
if len(spec.Columns) != 5 {
|
||||
t.Fatalf("got %d columns, want 5", len(spec.Columns))
|
||||
}
|
||||
want := []struct{ typ, format string }{
|
||||
{"string", "@"},
|
||||
{"number", ""},
|
||||
{"number", "$#,##0.00"},
|
||||
{"bool", ""},
|
||||
{"date", "yyyy-mm-dd"},
|
||||
}
|
||||
for i, w := range want {
|
||||
if spec.Columns[i].Type != w.typ {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
|
||||
}
|
||||
if spec.Columns[i].Format != w.format {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
|
||||
}
|
||||
}
|
||||
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
// Row 0: every field present, types match what buildTypedCell will accept.
|
||||
row0 := spec.Rows[0]
|
||||
if row0[0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
|
||||
}
|
||||
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
|
||||
}
|
||||
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
|
||||
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
|
||||
}
|
||||
if row0[3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", row0[3])
|
||||
}
|
||||
if row0[4] != "2024-01-15" {
|
||||
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
|
||||
}
|
||||
|
||||
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
|
||||
// so buildTypedCell paints an empty cell that still carries number_format.
|
||||
row1 := spec.Rows[1]
|
||||
for _, c := range []int{0, 1, 2} {
|
||||
if row1[c] != nil {
|
||||
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
|
||||
}
|
||||
}
|
||||
if row1[3] != false {
|
||||
t.Errorf("row1[active] = %#v, want false", row1[3])
|
||||
}
|
||||
if row1[4] != "2024-02-02" {
|
||||
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_Timestamp pins the timestamp → date conversion for the
|
||||
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
|
||||
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
|
||||
// landing — guard against TZ drift from the wrong unit pick.
|
||||
func TestDataframe_Timestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
|
||||
}, nil)
|
||||
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
|
||||
})
|
||||
spec, err := decodeArrowToSheet(buf, "S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Columns[0].Type != "date" {
|
||||
t.Errorf("type = %q, want date", spec.Columns[0].Type)
|
||||
}
|
||||
if got := spec.Rows[0][0]; got != "2024-06-12" {
|
||||
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
|
||||
// a 0-column "DataFrame" would write a header-less, data-less block that
|
||||
// validates as "writer ran successfully" but produces nothing — the test ties
|
||||
// that off as an explicit error rather than letting it slip through.
|
||||
func TestDataframe_EmptySchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema(nil, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "no fields") {
|
||||
t.Errorf("err = %v, want 'no fields' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
|
||||
// time. Validate already rejects duplicate column names for the JSON path;
|
||||
// the Arrow path mirrors that so the error surfaces with the same shape.
|
||||
func TestDataframe_DuplicateColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "x", Type: arrow.BinaryTypes.String},
|
||||
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
|
||||
}, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).Append("")
|
||||
b.Field(1).(*array.Int64Builder).Append(0)
|
||||
})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("err = %v, want duplicate-column error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
|
||||
// pandas df.to_feather so users see what producer is expected without having
|
||||
// to grep the docs.
|
||||
func TestDataframe_BadBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "Arrow") {
|
||||
t.Errorf("err = %v, want Arrow-decode error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
|
||||
// own decoder: build a +table-get-shaped sheet map (the same one
|
||||
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
|
||||
// decoder, and require the column types / formats / row values to match. If
|
||||
// any encoder choice drifts from what the decoder expects, the round-trip
|
||||
// breaks here long before a real put → get round-trip in production would.
|
||||
func TestDataframe_EncodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
sheet := map[string]interface{}{
|
||||
"name": "S1",
|
||||
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
|
||||
"dtypes": map[string]interface{}{
|
||||
"name": "object",
|
||||
"qty": "float64",
|
||||
"price": "float64",
|
||||
"active": "bool",
|
||||
"ts": "datetime64[ns]",
|
||||
},
|
||||
"formats": map[string]interface{}{
|
||||
// `@` is the writer convention for string columns; readSheetAsSpec
|
||||
// strips it via isTextNumberFormat, so an Arrow file built from a
|
||||
// real read won't carry @ either. Keep it absent here to mirror
|
||||
// the production wire shape.
|
||||
"price": "$#,##0.00",
|
||||
},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
|
||||
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
|
||||
},
|
||||
}
|
||||
blob, err := encodeSheetMapToArrowIPC(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
wantTypes := []string{"string", "number", "number", "bool", "date"}
|
||||
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
|
||||
if len(spec.Columns) != len(wantTypes) {
|
||||
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
|
||||
}
|
||||
for i, w := range wantTypes {
|
||||
if spec.Columns[i].Type != w {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
|
||||
}
|
||||
if spec.Columns[i].Format != wantFormats[i] {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
|
||||
}
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
if spec.Rows[0][0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
|
||||
}
|
||||
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
|
||||
}
|
||||
if spec.Rows[0][3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
|
||||
}
|
||||
if spec.Rows[0][4] != "2024-01-15" {
|
||||
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
|
||||
}
|
||||
// qty is null on row1, must come back as nil (not a zero-valued
|
||||
// json.Number that would later round-trip as 0).
|
||||
if spec.Rows[1][1] != nil {
|
||||
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
|
||||
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
|
||||
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
|
||||
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
|
||||
// output — early on the production shape silently fell through the
|
||||
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
|
||||
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := func(data interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "S",
|
||||
"columns": []interface{}{"city"},
|
||||
"dtypes": map[string]interface{}{"city": "object"},
|
||||
"data": data,
|
||||
}
|
||||
}
|
||||
for label, data := range map[string]interface{}{
|
||||
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
|
||||
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
|
||||
} {
|
||||
blob, err := encodeSheetMapToArrowIPC(base(data))
|
||||
if err != nil {
|
||||
t.Errorf("%s: encode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S")
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
|
||||
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
|
||||
// dtype families +table-get emits today plus the safe fallback for unknown
|
||||
// labels (string, lossless).
|
||||
func TestDataframe_DtypeToInternalType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"float64": "number",
|
||||
"int64": "number",
|
||||
"Int64": "number",
|
||||
"bool": "bool",
|
||||
"boolean": "bool",
|
||||
"datetime64[ns]": "date",
|
||||
"datetime64[ms]": "date",
|
||||
"object": "string",
|
||||
"": "string",
|
||||
"weird-new-dtype": "string",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := dtypeToInternalType(in); got != want {
|
||||
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
|
||||
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
|
||||
// offset: write some bytes, seek back to the middle, overwrite, end up with
|
||||
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
|
||||
func TestDataframe_BytesWriterSeeker(t *testing.T) {
|
||||
t.Parallel()
|
||||
var w bytesWriterSeeker
|
||||
if _, err := w.Write([]byte("hello world")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Seek(6, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte("WORLD")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(w.buf); got != "hello WORLD" {
|
||||
t.Errorf("buf = %q, want \"hello WORLD\"", got)
|
||||
}
|
||||
}
|
||||
197
shortcuts/sheets/lark_sheet_history.go
Normal file
197
shortcuts/sheets/lark_sheet_history.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_history ───────────────────────────────────────────────
|
||||
//
|
||||
// Wraps:
|
||||
// - list_history_versions (read) — powers +history-list
|
||||
// - revert_to_revision (write) — powers +history-revert
|
||||
// - get_revert_status (read) — powers +history-revert-status
|
||||
//
|
||||
// The version-history "方案 B": list the spreadsheet's saved revisions, submit
|
||||
// a whole-document revert to one of them, then poll the async revert task for
|
||||
// completion. The facade gateway owns the work; the CLI only forwards the
|
||||
// target revision (and later the task id) and the server does the rest.
|
||||
//
|
||||
// ⚠️ Full-table overwrite: +history-revert rolls the WHOLE spreadsheet back to
|
||||
// the target revision, discarding every change made afterwards — including
|
||||
// other collaborators' (and the web UI's) edits. Use it only on agent scratch
|
||||
// spreadsheets, or when a whole-document rollback is acceptable.
|
||||
|
||||
// HistoryList wraps list_history_versions: page through the spreadsheet's saved
|
||||
// revisions so a later +history-revert can target one by revision_id.
|
||||
var HistoryList = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-list",
|
||||
Description: "List the spreadsheet's saved history versions (paginated); use a returned revision_id with +history-revert.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+history-list"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := resolveSpreadsheetToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "list_history_versions", historyListInput(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "list_history_versions", historyListInput(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Omit --cursor for the first page; pass the previous response's next_cursor to fetch the next page.",
|
||||
"Pick a revision_id from a listing entry and pass it (plus that entry's edit_time to --edit-time) to +history-revert to roll the whole spreadsheet back.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevert wraps revert_to_revision: roll the whole spreadsheet back to a
|
||||
// past revision. Returns an async task id to poll via +history-revert-status.
|
||||
var HistoryRevert = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert",
|
||||
Description: "Roll the whole spreadsheet back to a past revision (full-document restore; discards all later edits).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+history-revert"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := historyRevertInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := historyRevertInput(runtime)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "revert_to_revision", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := historyRevertInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "revert_to_revision", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"+history-revert is a FULL-DOCUMENT rollback — it discards every edit made after the target version, including other collaborators'.",
|
||||
"--revision-id takes a revision_id (minor id) from +history-list; pass the same entry's edit_time to --edit-time to locate the version faster. Poll the returned task id with +history-revert-status.",
|
||||
},
|
||||
}
|
||||
|
||||
// HistoryRevertStatus wraps get_revert_status: poll an async revert task
|
||||
// started by +history-revert for completion.
|
||||
var HistoryRevertStatus = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Poll the status of an async revert task started by +history-revert.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+history-revert-status"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := historyRevertStatusInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := historyRevertStatusInput(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_revert_status", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := historyRevertStatusInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_revert_status", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"--task-id is the task id returned by +history-revert; re-run this until the task reports completion.",
|
||||
},
|
||||
}
|
||||
|
||||
// historyListInput builds the list_history_versions tool body. Network-free;
|
||||
// shared by DryRun and Execute. Both flags are optional: cursor is forwarded
|
||||
// only when set, count only when a positive value is given.
|
||||
func historyListInput(runtime flagView) map[string]interface{} {
|
||||
input := map[string]interface{}{}
|
||||
if cursor := strings.TrimSpace(runtime.Str("cursor")); cursor != "" {
|
||||
input["cursor"] = cursor
|
||||
}
|
||||
if count := runtime.Int("count"); count > 0 {
|
||||
input["count"] = count
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// historyRevertInput builds the revert_to_revision tool body. Network-free;
|
||||
// shared by Validate, DryRun, and Execute. revision_id 是 +history-list 返回的
|
||||
// revision_id(minor id);edit_time 可选,传同一条 entry 的 edit_time 让服务端更快定位该版本。
|
||||
func historyRevertInput(runtime flagView) (map[string]interface{}, error) {
|
||||
rev := strings.TrimSpace(runtime.Str("revision-id"))
|
||||
if rev == "" {
|
||||
return nil, common.FlagErrorf("--revision-id is required (a revision_id from +history-list)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"revision_id": rev,
|
||||
}
|
||||
if et := strings.TrimSpace(runtime.Str("edit-time")); et != "" {
|
||||
input["edit_time"] = et
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// historyRevertStatusInput builds the get_revert_status tool body.
|
||||
// Network-free; shared by Validate, DryRun, and Execute.
|
||||
func historyRevertStatusInput(runtime flagView) (map[string]interface{}, error) {
|
||||
tid := strings.TrimSpace(runtime.Str("task-id"))
|
||||
if tid == "" {
|
||||
return nil, common.FlagErrorf("--task-id is required")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"task_id": tid,
|
||||
}, nil
|
||||
}
|
||||
@@ -49,6 +49,12 @@ type objectCRUDSpec struct {
|
||||
// right nesting level.
|
||||
enhanceCreateInput func(rt flagView, input map[string]interface{})
|
||||
enhanceUpdateInput func(rt flagView, input map[string]interface{})
|
||||
// validateCreateInput, when set, runs after enhanceCreateInput to
|
||||
// enforce *cross-flag, create-only* constraints JSON Schema can't
|
||||
// express (e.g. pivot rejects --target-position vs --range when
|
||||
// both carry non-default values — they map to the same wire field
|
||||
// and conflicting values are ambiguous). Mirrors validateUpdateInput.
|
||||
validateCreateInput func(rt flagView, input map[string]interface{}) error
|
||||
// validateUpdateInput, when set, runs after enhanceUpdateInput to
|
||||
// enforce *cross-field, update-only* constraints JSON Schema can't
|
||||
// express (e.g. sparkline requires properties.sparklines[i] to
|
||||
@@ -190,6 +196,11 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
if spec.enhanceCreateInput != nil {
|
||||
spec.enhanceCreateInput(runtime, input)
|
||||
}
|
||||
if spec.validateCreateInput != nil {
|
||||
if err := spec.validateCreateInput(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -381,9 +392,6 @@ var pivotSpec = objectCRUDSpec{
|
||||
},
|
||||
createWarn: pivotPlacementWarn,
|
||||
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
input["target_position"] = v
|
||||
}
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return
|
||||
@@ -391,10 +399,26 @@ var pivotSpec = objectCRUDSpec{
|
||||
if v := strings.TrimSpace(rt.Str("source")); v != "" {
|
||||
props["source"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
// --target-position 与 --range 都映射到 properties.range;
|
||||
// --target-position 优先,未给(或为默认值 A1)时回落到 --range。
|
||||
// 互斥校验在 validateCreateInput 里做。
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
props["range"] = v
|
||||
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
props["range"] = v
|
||||
}
|
||||
},
|
||||
// --target-position 与 --range 落到同一 wire 字段(properties.range),
|
||||
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
|
||||
// 的处理方式,CLI 端直接拒绝(优于静默丢弃其一)。
|
||||
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
|
||||
pos := strings.TrimSpace(rt.Str("target-position"))
|
||||
rng := strings.TrimSpace(rt.Str("range"))
|
||||
if pos != "" && pos != "A1" && rng != "" {
|
||||
return common.FlagErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var PivotCreate = newObjectCreateShortcut(pivotSpec)
|
||||
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
|
||||
|
||||
@@ -137,25 +137,24 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
// covered separately in the +pivot-create empty-selector / mutex
|
||||
// tests below.
|
||||
{
|
||||
name: "+pivot-create with placement / source / range flags",
|
||||
name: "+pivot-create with placement / source / target-position flags",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--range", "F1",
|
||||
"--target-position", "B5",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"target_position": "B5",
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
"range": "F1",
|
||||
// --target-position 映射到 properties.range。
|
||||
"range": "B5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -507,6 +506,55 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
|
||||
// and --range cannot both be set" guardrail on +pivot-create. They map to
|
||||
// the same wire field (properties.range), so two non-default values are
|
||||
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
|
||||
// --target-sheet-name mutex). --target-position=A1 is the documented default
|
||||
// and is treated as "not set" — pairing it with --range still works.
|
||||
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("both non-default values rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "B5",
|
||||
"--range", "F1",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject --target-position with --range; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
if !strings.Contains(combined, "--target-position") || !strings.Contains(combined, "--range") {
|
||||
t.Errorf("expected error to quote both --target-position and --range; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "A1",
|
||||
"--range", "F1",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if got, _ := props["range"].(string); got != "F1" {
|
||||
t.Errorf("properties.range = %q, want %q", got, "F1")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_SchemaValidates exercises the schema-driven
|
||||
// validator wired into objectCreateInput. The pivot create schema
|
||||
// doesn't constrain rows/columns/values to be present (the backend
|
||||
|
||||
@@ -5,8 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -164,12 +162,7 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -219,141 +212,6 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
@@ -377,89 +235,6 @@ func a1EndRow(rng string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
|
||||
@@ -63,20 +63,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -179,113 +165,3 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
|
||||
1538
shortcuts/sheets/lark_sheet_table_io.go
Normal file
1538
shortcuts/sheets/lark_sheet_table_io.go
Normal file
File diff suppressed because it is too large
Load Diff
1338
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
1338
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
|
||||
// --output-path, +workbook-export delegates to the shared drive export core
|
||||
// with OutputDir="" so it creates + polls the export task and returns the ready
|
||||
// file token without writing a local file (downloaded=false).
|
||||
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_export"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"job_status": float64(0),
|
||||
"file_token": "ftk_xlsx",
|
||||
"file_name": "report.xlsx",
|
||||
"file_size": float64(2048),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
|
||||
}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("export-only execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if env.Data["ready"] != true {
|
||||
t.Errorf("ready = %v, want true", env.Data["ready"])
|
||||
}
|
||||
if env.Data["downloaded"] != false {
|
||||
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
|
||||
}
|
||||
if env.Data["file_token"] != "ftk_xlsx" {
|
||||
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
|
||||
}
|
||||
if env.Data["doc_type"] != "sheet" {
|
||||
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
|
||||
}
|
||||
}
|
||||
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// chdirTemp switches into a fresh temp dir for the duration of the test and
|
||||
// restores the original cwd afterwards. +workbook-import is the first sheets
|
||||
// shortcut that stat()s a real local file, so these tests need a working dir.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
|
||||
// shared drive import core and hard-codes the import target type to "sheet".
|
||||
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
|
||||
|
||||
var createBody map[string]interface{}
|
||||
for _, c := range calls {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
|
||||
createBody, _ = cm["body"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if createBody == nil {
|
||||
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
|
||||
}
|
||||
if createBody["type"] != "sheet" {
|
||||
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
|
||||
}
|
||||
if createBody["file_extension"] != "xlsx" {
|
||||
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
|
||||
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
|
||||
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
|
||||
// front and the error surfaces through the normal envelope/err path.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
|
||||
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
|
||||
// flow against stubs and asserts the resulting URL is a /sheets/ link.
|
||||
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_sheet"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"token": "shtcn_imported",
|
||||
"type": "sheet",
|
||||
"job_status": float64(0),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("import execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("execute output has no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
|
||||
t.Errorf("imported url = %q, want a /sheets/ link", url)
|
||||
}
|
||||
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
|
||||
t.Errorf("token = %q, want shtcn_imported", tok)
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,10 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -145,6 +141,28 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
|
||||
"tab_color": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "show_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -288,7 +306,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
|
||||
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
|
||||
@@ -300,12 +318,11 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
|
||||
t.Run("with values → 2-step plan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95],["bob",88]]`,
|
||||
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
@@ -317,7 +334,88 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
body, _ := fill["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B3" {
|
||||
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
|
||||
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 2 {
|
||||
t.Fatalf("cells rows = %#v, want 2", input["cells"])
|
||||
}
|
||||
headerRow, _ := cells[0].([]interface{})
|
||||
firstHeader, _ := headerRow[0].(map[string]interface{})
|
||||
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
|
||||
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
|
||||
t.Errorf("first header style = %#v, want bold + background", firstStyle)
|
||||
}
|
||||
secondHeader, _ := headerRow[1].(map[string]interface{})
|
||||
if secondHeader["border_styles"] == nil {
|
||||
t.Errorf("second header missing border_styles: %#v", secondHeader)
|
||||
}
|
||||
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
|
||||
if secondStyle["number_format"] != "0" {
|
||||
t.Errorf("second header number_format = %#v, want 0", secondStyle)
|
||||
}
|
||||
dataRow, _ := cells[1].([]interface{})
|
||||
firstData, _ := dataRow[0].(map[string]interface{})
|
||||
if _, ok := firstData["cell_styles"]; ok {
|
||||
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
|
||||
}
|
||||
secondData, _ := dataRow[1].(map[string]interface{})
|
||||
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
|
||||
if secondDataStyle["font_color"] != "#0f7b0f" {
|
||||
t.Errorf("second data style = %#v, want font color", secondDataStyle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a","b"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
|
||||
b1, _ := row0[1].(map[string]interface{})
|
||||
b1s, _ := b1["cell_styles"].(map[string]interface{})
|
||||
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
|
||||
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
|
||||
}
|
||||
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
|
||||
a1, _ := row0[0].(map[string]interface{})
|
||||
a1s, _ := a1["cell_styles"].(map[string]interface{})
|
||||
if a1s["font_color"] != nil {
|
||||
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -330,8 +428,18 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
|
||||
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
|
||||
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
|
||||
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
|
||||
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -344,21 +452,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
|
||||
// shared drive export core: a single create-task POST (poll + download are
|
||||
// described inline rather than as separate api entries).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
t.Errorf("url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
@@ -366,22 +474,18 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
|
||||
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
|
||||
"--output-path", "/tmp/out.csv",
|
||||
})
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export missing sub_id: %#v", body)
|
||||
}
|
||||
dl := calls[2].(map[string]interface{})
|
||||
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
|
||||
t.Errorf("download url = %v", dl["url"])
|
||||
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -396,92 +500,6 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves typed request errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
|
||||
got := sheetsDownloadRequestError(in)
|
||||
if got != in {
|
||||
t.Fatalf("typed error was not preserved: got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadRequestError(errors.New("dial refused"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantCategory errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetryable bool
|
||||
}{
|
||||
{
|
||||
name: "5xx is retryable network server error",
|
||||
status: http.StatusBadGateway,
|
||||
wantCategory: errs.CategoryNetwork,
|
||||
wantSubtype: errs.SubtypeNetworkServer,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "404 is API not found",
|
||||
status: http.StatusNotFound,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeNotFound,
|
||||
},
|
||||
{
|
||||
name: "429 is retryable API rate limit",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeRateLimit,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "other 4xx is API unknown",
|
||||
status: http.StatusForbidden,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
|
||||
StatusCode: tt.status,
|
||||
RawBody: []byte("body"),
|
||||
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
|
||||
})
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
|
||||
}
|
||||
if p.Code != tt.status {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tt.status)
|
||||
}
|
||||
if p.LogID != "log123" {
|
||||
t.Fatalf("log_id = %q, want log123", p.LogID)
|
||||
}
|
||||
if p.Retryable != tt.wantRetryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertInputEquals compares the decoded tool input map against the wanted
|
||||
// fields. Extra fields in `got` are allowed (defaults, optional fields);
|
||||
// every key in `want` must match exactly.
|
||||
|
||||
@@ -197,12 +197,12 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
|
||||
// plain values. Use +cells-set for anything richer (formula / style / note).
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
|
||||
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
|
||||
var CsvPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+csv-put",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -427,6 +428,44 @@ func TestCellsSet_RequiresJSONArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_RejectsUnsupportedMentionType pins the mention_type enum in
|
||||
// data/flag-schemas.json (synced from the upstream tool schema): a rich_text
|
||||
// mention whose mention_type is outside MENTION_FILE_TYPE (here 6 = cloud
|
||||
// shared folder) is rejected by the schema validator at flag-parse time,
|
||||
// before it can reach the server and blow up pb serialization
|
||||
// ("mentionFileInfo.fileType: enum value expected").
|
||||
func TestCellsSet_RejectsUnsupportedMentionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := `[[{"rich_text":[{"type":"mention","text":"x","mention_type":6,"mention_token":"t"}]}]]`
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells, "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "mention_type") || !strings.Contains(combined, "not in enum") {
|
||||
t.Errorf("expected mention_type enum guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_AllowsValidMentionTypes confirms the guard lets through a
|
||||
// user @mention (mention_type 0) and a render-supported file type (22 = DOCX).
|
||||
func TestCellsSet_AllowsValidMentionTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mt := range []int{0, 22} {
|
||||
cells := fmt.Sprintf(`[[{"rich_text":[{"type":"mention","text":"x","mention_type":%d,"mention_token":"t"}]}]]`, mt)
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("mention_type %d: unexpected error: stdout=%s stderr=%s err=%v", mt, stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
|
||||
// rendered, including the parent_type=sheet_image upload metadata.
|
||||
func TestCellsSetImage_DryRun(t *testing.T) {
|
||||
|
||||
@@ -38,8 +38,11 @@ func shortcutList() []common.Shortcut {
|
||||
SheetHide,
|
||||
SheetUnhide,
|
||||
SheetSetTabColor,
|
||||
SheetShowGridline,
|
||||
SheetHideGridline,
|
||||
WorkbookCreate,
|
||||
WorkbookExport,
|
||||
WorkbookImport,
|
||||
|
||||
// lark_sheet_sheet_structure
|
||||
SheetInfo,
|
||||
@@ -56,6 +59,7 @@ func shortcutList() []common.Shortcut {
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
DropdownGet,
|
||||
TableGet,
|
||||
|
||||
// lark_sheet_search_replace
|
||||
CellsSearch,
|
||||
@@ -67,6 +71,7 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSetImage,
|
||||
CsvPut,
|
||||
DropdownSet,
|
||||
TablePut,
|
||||
|
||||
// lark_sheet_range_operations
|
||||
CellsClear,
|
||||
@@ -103,5 +108,10 @@ func shortcutList() []common.Shortcut {
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
|
||||
// lark_sheet_history
|
||||
HistoryList,
|
||||
HistoryRevert,
|
||||
HistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 2.0.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。"
|
||||
version: 3.0.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -40,18 +40,25 @@ metadata:
|
||||
| --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | — |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put`(列显式声明 `type` + `format`,类型保真;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells) | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢,详见 workbook) | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — |
|
||||
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | — |
|
||||
| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | — |
|
||||
| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` |
|
||||
| 查找并替换 | `+cells-replace` | — |
|
||||
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` |
|
||||
| 看工作簿 / 子表清单 | `+workbook-info` | — |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
|
||||
| 导入本地 xlsx/xls/csv 文件为新表格 | `+workbook-import --file ./x.xlsx`(仅导入为电子表格;要导成多维表格走 `drive +import --type bitable`) | 把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(丢原格式、低效) |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
|
||||
> ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`;只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 → `+table-put`(写入已有表;声明 `type` + `format`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`);只有纯文本才用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
|
||||
|
||||
@@ -63,28 +70,28 @@ metadata:
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 |
|
||||
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 |
|
||||
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 |
|
||||
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。 |
|
||||
|
||||
### 按对象的工具参考(含 shortcut)
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 |
|
||||
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 |
|
||||
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。仅针对飞书表格。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。仅针对飞书表格。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 |
|
||||
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
|
||||
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 |
|
||||
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
|
||||
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。 |
|
||||
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。 |
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
@@ -102,7 +109,7 @@ metadata:
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
|
||||
- **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。
|
||||
- **例外**:`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets`,`+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。
|
||||
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
|
||||
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。
|
||||
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本文不重复。`+dropdown-delete` 不涉及这些 flag。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
- **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
|
||||
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
|
||||
- **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。
|
||||
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。
|
||||
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。**日期轴同理**:横轴显示成 `45297` 这类 Excel 序列号,是因为源日期列没设日期格式——给源列设 `number_format="yyyy-mm-dd"` 后横轴才会显示成日期(反例:折线图横轴日期显示为序列号)。大数值轴显示科学计数法同理,给源列设整数 / 千分位格式(反例:透视表数值轴显示科学计数法)。
|
||||
- **轴口径要对齐用户要的指标**:用户要"占比 / 比例"时,**纵轴应是百分比**——用饼图,或柱 / 条形图设 `stack.percentage: true` 让纵轴变 %,并把数据源指向占比列 / 让数据标签显示百分比;不要交付纵轴仍是原始计数的图(反例:要求看各类占比,却用普通堆积柱、纵轴是 0–350 的人数而非百分比)。
|
||||
- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确
|
||||
|
||||
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。**
|
||||
@@ -84,15 +85,17 @@
|
||||
|
||||
1. **查尺寸**:`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount;`+sheet-info` 只返回布局,不含行列总数)。
|
||||
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。
|
||||
3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
|
||||
3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(`position.row` 为 **0-based**:首行 = `row:0`,与 A1 区间 / `+dim-insert --position` 的 1-based 行号不同;col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
|
||||
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
|
||||
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`;
|
||||
- 否则先调 `+dim-insert`(`lark-sheets-sheet-structure`)扩行/列,再 create。
|
||||
|
||||
⚠️ **图表落点禁止压在已有数据矩形内**——必须落在数据区**右侧或下方的空白**,否则图表浮层会遮挡原始数据被判失败(反例:折线图落在数据区中间,遮挡了下方原始数据)。
|
||||
|
||||
**示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
|
||||
- ❌ `{row: 0, col: "W"}` — col=22 越界
|
||||
- ✅ `{row: 42, col: "A"}` — 放数据下方
|
||||
- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U 列后插 6 列;U=index 20,after 即从 21 起),再放图到 `{row: 0, col: "V"}`
|
||||
- ✅ 先 `+dim-insert --position V --count 6`(在 V 列前插 6 列,即 U 列之后),再放图到 `{row: 0, col: "V"}`
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -147,9 +150,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
_创建/更新的图表属性_
|
||||
|
||||
**顶层字段**:
|
||||
- `position` (object) — 必填 { row: number, col: string }
|
||||
- `position` (object?) — 必填 { row: number, col: string }
|
||||
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
|
||||
- `size` (object) — 必填 { width: number, height: number }
|
||||
- `size` (object?) — 必填 { width: number, height: number }
|
||||
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
|
||||
|
||||
## Examples
|
||||
@@ -164,24 +167,28 @@ _创建/更新的图表属性_
|
||||
|
||||
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**(1-based,对应 `refs.value` 范围内的列序)。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
|
||||
|
||||
> ⚠️ **含 `'Sheet'!` 前缀的 `--properties` 必须走 stdin 或 `@file`,不要用 inline 单引号**。`refs` / `nameRef` 里的 sheet 前缀带单引号(`'Sheet1'!A1`),若塞进 inline 的 `--properties '{...}'`,bash 会把内层那对单引号吃掉(sheet 名带空格还会被拆成多个词),JSON 直接被破坏。下面示例统一用 `--properties - <<'JSON' … JSON`(heredoc 定界符加引号 = 不做 shell 替换),或 `--properties @file.json`(`@` 只接 cwd 下相对路径)。
|
||||
|
||||
最小可用列图(inline 模式:refs 含表头行):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--sheet-name "Sheet1" --properties '{
|
||||
"position":{"row":42,"col":"A"},
|
||||
"size":{"width":600,"height":400},
|
||||
"snapshot":{
|
||||
"data":{
|
||||
"refs":[{"value":"'Sheet1'!A1:B10"}],
|
||||
"dim1":{"serie":{"index":1}},
|
||||
"dim2":{"series":[{"index":2}]}
|
||||
},
|
||||
"plotArea":{"plot":{"type":"column"}}
|
||||
}
|
||||
}'
|
||||
--sheet-name "Sheet1" --properties - <<'JSON'
|
||||
{
|
||||
"position":{"row":42,"col":"A"},
|
||||
"size":{"width":600,"height":400},
|
||||
"snapshot":{
|
||||
"data":{
|
||||
"refs":[{"value":"'Sheet1'!A1:B10"}],
|
||||
"dim1":{"serie":{"index":1}},
|
||||
"dim2":{"series":[{"index":2}]}
|
||||
},
|
||||
"plotArea":{"plot":{"type":"column"}}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
# 走文件(推荐配置较多时)
|
||||
# 或落到 cwd 下相对路径文件再用 @file
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json
|
||||
```
|
||||
|
||||
@@ -190,7 +197,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @ch
|
||||
饼图比 column / bar 更复杂:`sectors` 是 object,里面再包一个**单数** `sector` 数组——CLI 不替你 normalize,写错路径会被 server schema 直接拒。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties - <<'JSON'
|
||||
{
|
||||
"position":{"row":24,"col":"F"},
|
||||
"size":{"width":600,"height":450},
|
||||
"snapshot":{
|
||||
@@ -208,7 +216,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
"dim2":{"series":[{"index":2,"aggregateType":"sum"}]}
|
||||
}
|
||||
}
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
**数据与表头分离(必须用 `detached` + `nameRef`)**:
|
||||
@@ -216,7 +225,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
场景:周度销量明细表,真实表头在第 1 行(A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 11–17 行)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties - <<'JSON'
|
||||
{
|
||||
"position":{"row":7,"col":"F"},
|
||||
"size":{"width":600,"height":360},
|
||||
"snapshot":{
|
||||
@@ -233,7 +243,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
|
||||
]}
|
||||
}
|
||||
}
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 概览
|
||||
|
||||
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill,本文用指针引到那里,不重复展开。
|
||||
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应 reference,本文用指针引到那里,不重复展开。
|
||||
|
||||
**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属):
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
|
||||
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
|
||||
|
||||
## 铁律(所有编辑类任务必须满足,子 skill 不得放宽)
|
||||
## 铁律(所有编辑类任务必须满足,各 reference 不得放宽)
|
||||
|
||||
1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。
|
||||
1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。**改写 / 转换类任务要精确圈定适用行列**:只对任务真正要求的对象做变换,**不该转的行 / 列保持原值 1:1**(典型反例:要求"统一翻译"时把本就是中文、应原样保留的评论也重新翻译;要求"改写某列格式"时连原始测量值也一并改动 → 应保留的原文被篡改)。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。**收尾前必须确认产物文件真实存在 / 可导出**——别在没真正生成产物时只凭文本"已完成"就结束(反例:文本称已完成,实际没生成产物文件,等于没交付)。
|
||||
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
|
||||
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。
|
||||
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。**即使用户没说"联动 / 自动更新",凡是可由表内其它单元格推导的派生值(年龄=当年-出生年、占比=本类数/总数、达标=阈值判断、排名、各类分组汇总)默认就必须用公式**——用户默认期望派生列能随源数据重算,**离线 Python / 脚本算完写静态值,即便当前数值正确,改了源数据也不会自动更新,等于没满足"派生"的本意**(反例:年龄、月度汇总、占比、分组求和等派生列写死值,源数据一改结果就过时)。
|
||||
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
|
||||
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set),必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`。
|
||||
7. **分组汇总必须用透视表**:"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
|
||||
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。
|
||||
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。**题面 / 表头里写明的格式规范也是子要点**:表头注明"需标注某字段"就必须给对应单元格加规定前缀并逐条 assert 前缀存在(反例:漏加规定前缀,该要点即不达标);"相同编号连续行合并"必须遍历所有相同编号组全部合并(反例:只合并了其中一部分组)。
|
||||
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
|
||||
|
||||
## 推荐工作流程
|
||||
|
||||
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill(避免读一个调一个),本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。
|
||||
1. **规划 reference 清单**:开工前一次性列出本任务要读的 reference(避免读一个调一个),本轮已读过的不重复读。本文 + `lark-sheets-workbook` 几乎每次都要。
|
||||
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。
|
||||
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**:
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**:BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`。
|
||||
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
|
||||
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
|
||||
- **写回的必须是纯单元格值,禁止把"值+样式标注"串当值写回**:本地脚本或某些 xlsx 解析库会把单元格渲染成 `甲方支行(V-Align: bottom)` 这种"值(样式)"字符串,CSV 字段还可能带包裹双引号。回写前必须**剥离括号样式标注、去掉残留引号**,只写原始值——否则样式描述会变成单元格的字面文本污染原数据(反例:排序后单元格值里被写进 `(V-Align: bottom)` 这类样式后缀文本,末尾还多一个双引号)。**排序本身优先用 `+range-sort` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。
|
||||
|
||||
## 公式策略
|
||||
|
||||
|
||||
@@ -109,6 +109,17 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
|
||||
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
|
||||
```
|
||||
|
||||
**`conditions[].type` × `compare_type` 取值**(`type` 决定可用的 `compare_type`;两者均必填):
|
||||
|
||||
| `type` | 可用 `compare_type` | `values` |
|
||||
|---|---|---|
|
||||
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
|
||||
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
|
||||
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
|
||||
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
|
||||
|
||||
> ⚠️ `text` 用 `equals` / `notEquals`(**带 s**),`number` / `multiValue` 用 `equal` / `notEqual`(**不带 s**)——别混。完整 schema 跑 `+filter-view-create --print-schema --flag-name properties`。
|
||||
|
||||
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
|
||||
|
||||
### `+filter-view-update`
|
||||
|
||||
@@ -102,6 +102,17 @@ lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
|
||||
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
|
||||
```
|
||||
|
||||
**`conditions[].type` × `compare_type` 取值**(`type` 决定可用的 `compare_type`;两者均必填):
|
||||
|
||||
| `type` | 可用 `compare_type` | `values` |
|
||||
|---|---|---|
|
||||
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
|
||||
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
|
||||
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
|
||||
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
|
||||
|
||||
> ⚠️ `text` 用 `equals` / `notEquals`(**带 s**),`number` / `multiValue` 用 `equal` / `notEqual`(**不带 s**)——别混。完整 schema 跑 `+filter-create --print-schema --flag-name properties`。
|
||||
|
||||
### `+filter-update`
|
||||
|
||||
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Lark Sheet Float Image
|
||||
|
||||
> **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具:
|
||||
> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark-sheets-write-cells)。
|
||||
> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-image-{create|update|delete}`。
|
||||
> **选浮动图还是单元格图?只看一条**:这张图是不是**属于某条记录、要随那行一起排序 / 筛选 / 增删**?
|
||||
> - **是 → 单元格图片**(不在本 reference):嵌进单元格、随行走。用 `+cells-set-image`(或 `+cells-set` 的 `rich_text` + `type: "embed-image"`,见 lark-sheets-write-cells)。典型:凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图;话里带「对应 / 每行 / 每条 / 这列」等绑定词即属此类。
|
||||
> - **否 → 浮动图片**(本 reference):自由摆放、不绑数据的装饰 / 标识(logo / 水印 / 封面大图 / banner)。
|
||||
> - ⚠️ 别凭"浮动图位置尺寸更好控制 / 更熟"就选它——那是按操作便利选,不是按场景选;用浮动图承载"对应某记录"的图会在增删行 / 排序后错位。
|
||||
|
||||
## 真对象硬约束
|
||||
|
||||
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
|
||||
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set-image` / `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
|
||||
|
||||
## 使用场景
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **单元格图片 vs 浮动图片选择错误**:如果用户希望图片嵌入单元格内部(随单元格移动),应使用 `+cells-set` 的 `rich_text` + `embed-image`,而非本 Skill
|
||||
- **单元格图片 vs 浮动图片选择错误(最高频)**:图与某条记录一一对应、要随行排序 / 筛选 / 增删时,应走 `+cells-set-image`(见顶部判别),用浮动图会错位。
|
||||
- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据
|
||||
- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
|
||||
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
|
||||
|
||||
**打印场景控制总宽(用户说"适合打印 / A4 / 打印范围"时必做)**:扩单列宽防截断的同时,**所有列宽之和要落在纸张可打印宽度内**——A4 横向约 ≤ 102 个半角字符(约 1000px),纵向约 ≤ 70 个字符。超宽时不要无限加宽,改用 `cell_styles.word_wrap="auto-wrap"` + 调高行高,或缩窄非关键列,让整表在一页内(反例:总列宽远超 A4 可打印宽度,且长文本行高不够被截断)。
|
||||
|
||||
**只加宽承载新内容的列,不改动原有列的列宽**:列宽自适应**只针对新增 / 真正放不下新内容的列**;原表已有列的列宽**禁止重新计算、禁止缩小**——即便你估算的"理想宽度"与原值不同,只要原内容没被截断就不要动它。无差别地把所有列重设一遍宽度(哪怕只 ±1)都属于破坏原文件视觉格式(反例:填完数据后顺手把原有列的列宽从 16 改成 17,与原附件不一致,破坏了原视觉格式)。
|
||||
|
||||
**⚠️ 合并单元格安全操作规则**(`+cells-{merge|unmerge}` 必读):
|
||||
|
||||
1. **先读后写**:操作前必须用 `+sheet-info --include merges` 或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。
|
||||
@@ -192,7 +196,7 @@ _排序条件列表(仅 sort 操作)_
|
||||
|
||||
## Examples
|
||||
|
||||
> ⚠️ 本 skill 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。
|
||||
> ⚠️ 本 reference 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。这里统一从区域操作视角讲解。
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
|
||||
|
||||
|
||||
@@ -13,19 +13,22 @@
|
||||
|
||||
预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。
|
||||
|
||||
⚠️ **大数字(15 位以上的身份证 / 参考号 / 流水号)做去重 / 比较时禁止用 `+csv-get` 的显示值**:`+csv-get` 返回的是**格式化显示值**,15 位以上数字会被显示成 `1.04E+14` 这类科学计数法——多个本不相同的号在显示层全变成同一个 `1.04E+14`,拿去判重会**整列误判为重复**。比较 / 去重 / 匹配大数字时必须改用 `+cells-get`(取原始精确值)或把该列读为文本,禁止用 csv-get 的科学计数显示值(反例:大批长参考号被显示成科学计数后,互不相同的号全变成同一个值,被当成整列重复并错误高亮)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut,按读取目的选择:
|
||||
读取。从飞书表格中读取单元格数据。本 reference 覆盖 4 个 shortcut,按读取目的选择:
|
||||
|
||||
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|
||||
|---------|----------------|---------|------|
|
||||
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(加 `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
|
||||
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
|
||||
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put` |
|
||||
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 |
|
||||
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
|
||||
|
||||
**选择原则**:
|
||||
- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文
|
||||
- 要结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token,超大表批量仍用默认)
|
||||
- 要按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`)→ `+table-get`
|
||||
- 需要公式/样式/批注 → `+cells-get`
|
||||
- 只想知道某区域下拉框有哪些选项 → `+dropdown-get`
|
||||
|
||||
@@ -40,7 +43,7 @@
|
||||
注意:
|
||||
|
||||
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
|
||||
@@ -83,6 +86,7 @@
|
||||
| `+cells-get` | read | 单元格 |
|
||||
| `+dropdown-get` | read | 对象 |
|
||||
| `+csv-get` | read | 单元格 |
|
||||
| `+table-get` | read | 单元格 |
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -115,7 +119,18 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
|
||||
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
| `--rows-json` | bool | optional | 返回结构化 rows(`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` |
|
||||
|
||||
### `+table-get`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--sheet-id` | string | optional | 只读该子表(按 id);省略则读所有子表 |
|
||||
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
|
||||
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的当前数据区 |
|
||||
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
|
||||
| `--dataframe-out` | string | optional | 以一份 Arrow IPC 文件(Feather v2)格式输出 typed 表格,替代默认的 JSON 输出。用 `@<path>` 传文件或 `-` 写二进制 stdout(同其他 binary I/O flag 的约定)。是 `+table-put` / `+workbook-create` 入口 `--dataframe` 的镜像 —— pandas 端 `pd.read_feather("x.arrow")` 或 `pd.read_feather(io.BytesIO(stdout))` 一行读回。仅支持单 sheet:必须给 `--sheet-id` 或 `--sheet-name`;读整本 workbook 仍走默认 JSON。列类型沿用 typed 读回(string/number/date/bool);`number_format` 以 Arrow Field metadata 保留,Arrow 文件可直接喂回 `+table-put --dataframe`。 |
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -140,17 +155,7 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
|
||||
- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界**,**优先于 `+workbook-info` 的 `row_count`**(`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行)
|
||||
- `has_more` — 是否截断;截断后续读用 `--range` 接着读
|
||||
|
||||
**加 `--rows-json`:返回结构化 rows(而非 CSV 字符串)**
|
||||
|
||||
```bash
|
||||
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json
|
||||
```
|
||||
|
||||
`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`):
|
||||
|
||||
- `rows` — 数组,每元素 `{row_number, values}`。`row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key(如 `values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。
|
||||
- `data_not_fully_read` — **仅当没读全时出现**:`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。
|
||||
- 其余字段(`current_region` / `actual_range` / `has_more`)同上。
|
||||
> 要按列类型结构化读出(喂 DataFrame、或 round-trip 回 `+table-put`)用 `+table-get`(见下);`+csv-get` 给的是带 `[row=N]` 前缀的纯值快照,下游需要行号/列坐标时直接从前缀与 `col_indices` 取。
|
||||
|
||||
### `+cells-get`
|
||||
|
||||
@@ -164,6 +169,89 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --she
|
||||
|
||||
> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。
|
||||
|
||||
### `+table-get`(飞书 → DataFrame,类型保真读出)
|
||||
|
||||
`+table-put`(写入侧,见 write-cells reference)的镜像:把表格读回与 `--sheets` 完全同构的 typed 协议(`sheets[]` + `columns:[列名]` + `data:[[行]]` + `dtypes:{列名:pandas_dtype}` + `formats?:{列名:number_format}`),可直接喂回 `+table-put` 或一行还原 DataFrame。
|
||||
|
||||
列类型从每列 `number_format` 推断(日期格式→`date`/`datetime64[ns]`、数值→`number`/`float64`、bool→`bool`),`date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`(dtypes 输出 `object`),让 `dtypes` 与 `data` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas `astype` 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 底层复用 `get_cell_ranges` / `get_range_as_csv`。默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。
|
||||
|
||||
```bash
|
||||
# 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame)
|
||||
lark-cli sheets +table-get --url "<表URL>"
|
||||
# 可选:--sheet-name / --sheet-id 限定只读某一个子表(不给则读全部)
|
||||
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售"
|
||||
```
|
||||
|
||||
#### 输出 → DataFrame(2 行 helper)
|
||||
|
||||
输出形状对齐 pandas split:`columns` 是列名数组、`data` 是二维数据、`dtypes` 是 `{列名: pandas_dtype_str}` 映射。直接喂给 `pd.DataFrame(...).astype(...)` 就能一次性还原所有列类型(不必逐列 `to_datetime` / `to_numeric`),写入侧 `df_to_sheet` 的镜像 helper:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
def sheet_to_df(sheet):
|
||||
return pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])
|
||||
|
||||
# 单 sheet
|
||||
df = sheet_to_df(out["data"]["sheets"][0])
|
||||
|
||||
# 多 sheet——按名字取
|
||||
sheets = {s["name"]: sheet_to_df(s) for s in out["data"]["sheets"]}
|
||||
df_sales = sheets["销售"]
|
||||
```
|
||||
|
||||
> 显示格式(千分位、百分比、自定义日期)在 `sheet["formats"]`,pandas 不消费;改完数据 round-trip 回去时透传给 `+table-put` 即可,飞书侧显示不变。
|
||||
|
||||
#### `--dataframe-out`(Arrow IPC / Feather v2 二进制读出)
|
||||
|
||||
`--dataframe-out` 是 `+table-put` 入口 `--dataframe` 的镜像:把 typed 读回直接编码成 Arrow IPC 文件,pandas 端一行 `pd.read_feather()` 读回——省掉 JSON 解析 + `astype(dtypes)`,列类型 / `number_format` 走 Arrow schema + Field metadata 保真。**仅支持单 sheet**(Arrow 文件一 schema 容器),必须给 `--sheet-id` 或 `--sheet-name`;读整本 workbook 仍走默认 JSON。
|
||||
|
||||
```bash
|
||||
# 文件
|
||||
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" --dataframe-out @./out.arrow
|
||||
# binary stdout(不落盘)
|
||||
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" --dataframe-out -
|
||||
```
|
||||
|
||||
```python
|
||||
import io, pandas as pd, subprocess
|
||||
|
||||
# 1) 文件
|
||||
subprocess.run(["lark-cli","sheets","+table-get","--url",URL,
|
||||
"--sheet-name","销售","--dataframe-out","@./out.arrow"], check=True)
|
||||
df = pd.read_feather("./out.arrow")
|
||||
|
||||
# 2) stdin/stdout 管道(不落盘)—— 跟 --dataframe 写入侧对称的一行
|
||||
res = subprocess.run(["lark-cli","sheets","+table-get","--url",URL,
|
||||
"--sheet-name","销售","--dataframe-out","-"],
|
||||
capture_output=True, check=True)
|
||||
df = pd.read_feather(io.BytesIO(res.stdout))
|
||||
```
|
||||
|
||||
> `number_format` 进 Arrow Field metadata(key=`number_format`),Arrow 文件可以直接喂回 `+table-put --dataframe` round-trip 写回,types / formats 一路保真。
|
||||
|
||||
#### round-trip:读 → 改 → 写回(写读对偶)
|
||||
|
||||
`sheet_to_df` 和 write-cells reference 里的 `df_to_sheet` 是一对镜像 helper,round-trip 三段读 / 改 / 写各一行:
|
||||
|
||||
```python
|
||||
import json, subprocess
|
||||
# 1. 读
|
||||
out = json.loads(subprocess.check_output(
|
||||
["lark-cli","sheets","+table-get","--url",URL,"--sheet-name","销售"]))
|
||||
sheet = out["data"]["sheets"][0]
|
||||
df = sheet_to_df(sheet)
|
||||
|
||||
# 2. 改(pandas 操作)
|
||||
df["营收"] = df["营收"] * 1.1
|
||||
|
||||
# 3. 写回(formats 是飞书侧显示格式,pandas 不消费,透传保留显示)
|
||||
payload = {"sheets": [df_to_sheet(df, sheet["name"], formats=sheet.get("formats"))]}
|
||||
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--sheets","-"],
|
||||
input=json.dumps(payload).encode(), check=True)
|
||||
```
|
||||
|
||||
`sheet_to_df(sheet)` 消费 `(columns, data, dtypes)`,`df_to_sheet(df, name, formats=...)` 重新生成同样三个字段——读 / 写完全对偶,只有 `formats` 需要手工透传一次。
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **数据源范围要精确**:迷你图的数据源范围必须与实际数据行列精确对应,范围偏移会导致图形展示错误
|
||||
- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过本 Skill 的对象方式创建
|
||||
- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过 `+sparkline-{create|update|delete}` 的对象方式创建
|
||||
- **创建后必须验证**:调用 `+sparkline-list` 确认迷你图配置正确
|
||||
|
||||
## Shortcuts
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user