Compare commits

..

6 Commits

Author SHA1 Message Date
zhaoyukun.yk
78e9d4c597 feat(doc,markdown,sheets,slides,wiki): emit typed error envelopes across ccm domains
Classify doc, markdown, sheets, slides, and wiki shortcut failures with typed errors so CLI users and automation receive more specific, actionable diagnostics.

Keep the migration scoped to the CCM shortcut domains, their error-contract guards, and the now-unreachable legacy save-error helper cleanup required after those domains stop calling it. Align the common-helper AST guard with latest main's migrated paths without reintroducing shortcuts removed upstream.
2026-06-09 12:13:58 +08:00
evandance
03ea6e78b8 feat(contact): emit typed error envelopes across the contact domain (#1287) 2026-06-09 12:07:35 +08:00
ViperCai
ed3fe9337f fix(slides): build create URL locally instead of drive metas call (#1329)
slides +create finished by calling /drive/v1/metas/batch_query just to
fetch the presentation URL. That call needs a drive scope the shortcut
never declares, so it 403'd for users who only authorized slides scopes
(both UserAccessToken re-auth and TenantAccessToken scope-not-opened),
producing a large share of the shortcut's failure telemetry — even though
the presentation itself was already created successfully.

slides creation never otherwise touches drive, so rather than gating a
drive-free operation behind a drive scope, build the URL locally from the
token via common.BuildResourceURL (the same brand-standard-host fallback
already used by drive +upload / wiki +node-create). The URL is now always
returned, no extra scope is required, and creation never blocks.

Tests are updated to match: drop the registerBatchQueryStub helper and its
call sites (the httpmock Verify cleanup was failing on the now-unconsumed
batch_query stubs), point url assertions at the brand-standard host, and
replace TestSlidesCreateURLFetchBestEffort with TestSlidesCreateURLBuiltLocally,
which asserts the url is produced with no drive call registered.
2026-06-09 11:30:14 +08:00
ZEden0
cc416a4de5 docs(lark-doc): document <folder-manager> resource block (#1168)
- lark-doc-xml.md §三「资源块」: add <folder-manager wiki-token="..."> entry
  with full sub-page schema (title / url / file-type+doc-id fallback /
  space-id / owner / owner-id / create-time / edit-time, ms timestamps,
  has-more="true" beyond 100 children)
- lark-doc-xml.md §四「复制」: append folder-manager to copy support list
  (per spec FE-1 TC-D acceptance)
- lark-doc-xml.md §八 完整示例: add folder-manager example
- lark-doc-fetch.md: add 子页面列表 section explaining fetch behavior,
  url-first / file-type+doc-id fallback, container-only on wiki.core
  failure or no permission

Spec ref: cli-docx-folder-manager FE-1

Change-Id: I746fbebcc3398c5ec0b144f2eb2a306e6d96fb74
2026-06-09 10:46:03 +08:00
JackZhao10086
00d45f8fa2 feat: adjust agent timeout hint output conditions (#1328) 2026-06-09 10:05:11 +08:00
liangshuo-1
0d847511d2 chore(release): v1.0.49 (#1331) 2026-06-08 21:38:23 +08:00
122 changed files with 2702 additions and 2729 deletions

1
.gitignore vendored
View File

@@ -36,7 +36,6 @@ tests/mail/reports/
.hammer/
.lark-slides/
internal/registry/meta_data.json
internal/registry/metastatic/meta_data_gen.go
cmd/api/download.bin
app.log
/sidecar-server-demo

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -2,8 +2,6 @@ version: 2
before:
hooks:
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go),
# the sole source of the embedded command tree.
- python3 scripts/fetch_meta.py
builds:

View File

@@ -2,6 +2,46 @@
All notable changes to this project will be documented in this file.
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
@@ -1026,6 +1066,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46

View File

@@ -12,9 +12,6 @@ PREFIX ?= /usr/local
all: test
# fetch_meta fetches meta_data.json AND regenerates the static Go registry
# (internal/registry/metastatic/meta_data_gen.go) — the sole build-time source
# of the embedded command tree. Both are gitignored; build/vet/test depend on it.
fetch_meta:
python3 scripts/fetch_meta.py

View File

@@ -72,12 +72,10 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
// Brand only — never decrypt the app secret just to build help text
// (avoids a keychain read on every `auth login --help` / completion).
var helpBrand core.LarkBrand
if f != nil && f.ConfigBrand != nil {
if b, ok := f.ConfigBrand(); ok {
helpBrand = b
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
@@ -298,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 2: Show user code and verification URL.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
// capture stdout into a JSON parser see it without stream-mixing surprises.
// Text mode prints the hint to stderr only when running under a non-TTY
// (i.e. piped / agent harness), since humans reading a terminal don't need
// the agent-oriented instructions.
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -319,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
}
// Step 3: Poll for token
@@ -406,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
// Skip the stderr hint in JSON mode (the --no-wait call that issued
// the device_code already surfaced it as a JSON field), and also skip it
// when running on an interactive terminal — the agent-oriented
// instructions only matter for piped / harness environments.
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)

View File

@@ -1,87 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Tree-dump tool: dumps the full command tree (paths, flags, descriptions,
// annotations) in a canonical, line-stable form so two builds can be diffed
// byte-for-byte (e.g. before/after a registry change). Set LARK_TREE_DUMP=<path>
// to write the dump; otherwise the test is a no-op. Not a committed golden — the
// meta data is fetched/gitignored and drifts.
package cmd_test
import (
"context"
"fmt"
"os"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func esc(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\t", "\\t")
s = strings.ReplaceAll(s, "\r", "\\r")
return s
}
func dumpCommandTree(root *cobra.Command) string {
var lines []string
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
head := fmt.Sprintf("CMD %q use=%q short=%q long=%q runnable=%t hidden=%t",
path, esc(c.Use), esc(c.Short), esc(c.Long), c.Runnable(), c.Hidden)
lines = append(lines, head)
if len(c.Annotations) > 0 {
keys := make([]string, 0, len(c.Annotations))
for k := range c.Annotations {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
lines = append(lines, fmt.Sprintf(" ann %s=%q", k, esc(c.Annotations[k])))
}
}
var flags []string
c.Flags().VisitAll(func(f *pflag.Flag) {
flags = append(flags, fmt.Sprintf(" flag --%s -%s type=%s def=%q usage=%q",
f.Name, f.Shorthand, f.Value.Type(), esc(f.DefValue), esc(f.Usage)))
})
sort.Strings(flags)
lines = append(lines, flags...)
subs := c.Commands()
sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() })
for _, sub := range subs {
walk(sub)
}
}
walk(root)
return strings.Join(lines, "\n") + "\n"
}
func TestDumpCommandTree(t *testing.T) {
out := os.Getenv("LARK_TREE_DUMP")
if out == "" {
t.Skip("set LARK_TREE_DUMP=<path> to dump the command tree")
}
// Deterministic: embedded meta only (no remote cache), empty config dir so
// strict-mode/plugins/policy cannot reshape the tree.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
dump := dumpCommandTree(root)
if err := os.WriteFile(out, []byte(dump), 0644); err != nil {
t.Fatal(err)
}
t.Logf("wrote %d bytes, %d lines to %s", len(dump), strings.Count(dump, "\n"), out)
}

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -31,56 +30,74 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, spec := range registry.TypedServices() {
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, f)
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)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
svc := registry.MapToService(spec)
svc.Resources = registry.MapToResources(resources)
registerServiceWithContext(context.Background(), parent, svc, f)
registerServiceWithContext(context.Background(), parent, spec, resources, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
specDesc := registry.GetServiceDescription(spec.Name, "en")
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 = spec.Description
specDesc = registry.GetStrFromMap(spec, "description")
}
// Find existing service command or create one
var svc *cobra.Command
for _, c := range parent.Commands() {
if c.Name() == spec.Name {
if c.Name() == specName {
svc = c
break
}
}
if svc == nil {
svc = &cobra.Command{
Use: spec.Name,
Use: specName,
Short: specDesc,
}
parent.AddCommand(svc)
}
for _, resource := range spec.Resources {
registerResourceWithContext(ctx, svc, spec, resource, f)
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 metaschema.Service, resource metaschema.Resource, f *cmdutil.Factory) {
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: resource.Name,
Short: resource.Name + " operations",
Use: name,
Short: name + " operations",
}
parent.AddCommand(res)
for _, method := range resource.Methods {
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
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)
}
}
@@ -108,36 +125,31 @@ type ServiceMethodOptions struct {
FileFields []string // auto-detected file field names from metadata
}
// detectFileFieldsTyped returns the names of file-type fields in the method's
// request body (used to decide whether to register --file).
func detectFileFieldsTyped(m metaschema.Method) []string {
var fields []string
for _, fld := range m.RequestBody {
if fld.Type == "file" {
fields = append(fields, fld.Name)
}
}
return fields
// 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 metaschema.Service, method metaschema.Method, name string, resName string, f *cmdutil.Factory) {
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))
}
// NewCmdServiceMethod creates a command for a dynamically registered service
// method from map specs (kept for tests; converts to typed internally).
// 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, registry.MapToService(spec), registry.MapToMethod(name, method), name, resName, runF)
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec metaschema.Service, method metaschema.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := method.Description
httpMethod := method.HTTPMethod
risk := method.Risk
schemaPath := fmt.Sprintf("%s.%s.%s", spec.Name, resName, name)
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)
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
}
var asStr string
@@ -147,10 +159,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
RunE: func(cmd *cobra.Command, args []string) error {
// Materialize the maps the execution path still reads lazily — only
// when THIS command actually runs, never at startup.
opts.Spec = registry.ServiceToMap(spec)
opts.Method = registry.MethodToMap(method)
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
@@ -180,7 +188,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFieldsTyped(method)
fileFields := detectFileFields(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
@@ -192,15 +200,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
// meta_data.json carries no per-method tips; SetTips(nil) matches prior behavior.
cmdutil.SetTips(cmd, nil)
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
cmdutil.SetRisk(cmd, risk)
if len(method.AccessTokens) > 0 {
toks := make([]interface{}, len(method.AccessTokens))
for i, t := range method.AccessTokens {
toks[i] = t
}
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(toks))
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
}
return cmd

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
"github.com/spf13/cobra"
)
@@ -753,7 +752,7 @@ func TestDetectFileFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFieldsTyped(registry.MapToMethod("", tt.method))
got := detectFileFields(tt.method)
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return

View File

@@ -30,11 +30,10 @@ type InvocationContext struct {
}
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
ConfigBrand func() (core.LarkBrand, bool) // brand only, no secret decryption — for startup help/registration (avoids keychain)
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
@@ -152,14 +151,11 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if f.Credential == nil {
return core.StrictModeOff
}
// Strict mode is plain config metadata; resolve it WITHOUT decrypting the
// app secret so identity-flag registration at startup never touches the
// keychain (ResolveStrictMode is called per command during Build).
_, supported, ok := f.Credential.ResolveMeta(ctx)
if !ok {
acct, err := f.Credential.ResolveAccount(ctx)
if err != nil || acct == nil {
return core.StrictModeOff
}
ids := extcred.IdentitySupport(supported)
ids := extcred.IdentitySupport(acct.SupportedIdentities)
switch {
case ids.BotOnly():
return core.StrictModeBot

View File

@@ -78,18 +78,6 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// ConfigBrand resolves just the brand without decrypting the app secret, so
// brand-aware help and shortcut registration at startup do not touch the
// keychain. It still initializes the registry with the resolved brand — the
// same side effect Config has, minus the secret.
f.ConfigBrand = sync.OnceValues(func() (core.LarkBrand, bool) {
brand, _, ok := f.Credential.ResolveMeta(context.Background())
if ok {
registry.InitWithBrand(brand)
}
return brand, ok
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)

View File

@@ -65,13 +65,7 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
)
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
ConfigBrand: func() (core.LarkBrand, bool) {
if config != nil {
return config.Brand, true
}
return "", false
},
Config: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},

View File

@@ -21,14 +21,6 @@ type DefaultAccountResolver interface {
ResolveAccount(ctx context.Context) (*Account, error)
}
// metaResolver is an optional capability: resolve config metadata (brand +
// strict-mode identity support) without resolving the app secret (no keychain
// access). Providers that don't implement it fall back to ResolveAccount inside
// CredentialProvider.ResolveMeta.
type metaResolver interface {
ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool)
}
// DefaultTokenResolver is implemented by the default token provider.
type DefaultTokenResolver interface {
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
@@ -149,11 +141,6 @@ type CredentialProvider struct {
accountErr error
selectedSource credentialSource
metaOnce sync.Once
metaBrand core.LarkBrand
metaIdents uint8
metaOK bool
hintOnce sync.Once
hint *IdentityHint
hintErr error
@@ -185,44 +172,6 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
return p.account, p.accountErr
}
// ResolveMeta resolves config metadata — brand and strict-mode identity support
// — cheaply, WITHOUT decrypting the app secret for the default
// (config.json/keychain) provider. It mirrors doResolveAccount's provider
// selection: external providers (env/sidecar) are asked first via ResolveAccount
// (they do not touch the keychain), then the default provider's keychain-free
// metaResolver path. Cached after first call. Best-effort: returns ok=false when
// nothing is configured, so callers keep their defaults. Used for brand-aware
// help text, shortcut registration, and strict-mode checks at startup, where
// decrypting the secret would be wasteful.
func (p *CredentialProvider) ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
p.metaOnce.Do(func() {
p.metaBrand, p.metaIdents, p.metaOK = p.doResolveMeta(ctx)
})
return p.metaBrand, p.metaIdents, p.metaOK
}
func (p *CredentialProvider) doResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
return "", 0, false
}
if acct != nil {
internal := convertAccount(acct)
return internal.Brand, internal.SupportedIdentities, true
}
}
if p.defaultAcct != nil {
if mr, ok := p.defaultAcct.(metaResolver); ok {
return mr.ResolveMeta(ctx)
}
if acct, err := p.defaultAcct.ResolveAccount(ctx); err == nil && acct != nil {
return acct.Brand, acct.SupportedIdentities, true
}
}
return "", 0, false
}
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)

View File

@@ -76,23 +76,6 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return AccountFromCliConfig(cfg), nil
}
// ResolveMeta returns config metadata — brand and the strict-mode identity
// support — from config.json WITHOUT resolving the app secret (no keychain
// access). Both are plain config fields, so brand-aware help, shortcut
// registration, and strict-mode checks at startup need not decrypt the secret.
// Returns ok=false when no config exists, so callers keep their defaults.
func (p *DefaultAccountProvider) ResolveMeta(_ context.Context) (core.LarkBrand, uint8, bool) {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "", 0, false
}
app := multi.CurrentAppConfig(p.profile)
if app == nil {
return "", 0, false
}
return app.Brand, strictModeToIdentitySupport(multi, p.profile), true
}
// strictModeToIdentitySupport maps the config-level strict mode to
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {

View File

@@ -19,32 +19,72 @@ import (
//go:embed scope_priorities.json scope_overrides.json
var registryFS embed.FS
// EmbeddedSpec returns the embedded baseline spec for one service as a map, or
// nil if the service is unknown. It reads the static compile-time registry
// (metastatic.Registry) and bypasses the remote overlay, so envelope output is
// deterministic across machines.
func EmbeddedSpec(serviceName string) map[string]interface{} {
if svc, ok := baselineServiceByName(serviceName); ok {
return ServiceToMap(svc)
}
return nil
// 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
}
// EmbeddedServiceNames returns the embedded baseline service names, sorted
// (no remote overlay).
var (
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
embeddedServiceNames []string // sorted
embeddedParseOnce sync.Once
)
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
// without touching mergedServices. Safe to call multiple times (sync.Once).
func parseEmbeddedServices() {
embeddedParseOnce.Do(func() {
embeddedServicesMap = make(map[string]map[string]interface{})
if len(embeddedMetaJSON) == 0 {
return
}
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 {
svcs := baselineServices()
out := make([]string, 0, len(svcs))
for _, s := range svcs {
out = append(out, s.Name)
}
sort.Strings(out)
parseEmbeddedServices()
out := make([]string, len(embeddedServiceNames))
copy(out, embeddedServiceNames)
return out
}
var (
embeddedVersion string // baseline data version (from the static registry)
initOnce sync.Once
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
mergedProjectList []string // sorted project names
embeddedVersion string // version from embedded meta_data.json
initOnce sync.Once
)
// Init initializes the registry with default brand (feishu).
@@ -61,27 +101,55 @@ func Init() {
func InitWithBrand(brand core.LarkBrand) {
initOnce.Do(func() {
configuredBrand = brand
// 1. Baseline version: the static compile-time registry (metastatic).
embeddedVersion = baselineVersion()
// 2. Remote overlay — still fetched/refreshed at runtime, decoded into
// the same typed shape and merged over the baseline.
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
loadEmbeddedIntoMerged()
// 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)
if !brandChanged {
_ = loadCachedTyped()
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
}
if !hasTypedData() || brandChanged {
// No data at all (e.g. stub build, no cache) or brand changed.
if len(mergedServices) == 0 || brandChanged {
// No data at all or brand changed — must sync fetch
doSyncFetch()
} else if shouldRefresh(meta) || metaErr != nil {
// Have embedded/cached data; refresh in background if TTL expired or first run
triggerBackgroundRefresh()
}
}
// 3. Build sorted project list
rebuildProjectList()
})
}
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
// mergedServices. No-op if meta_data.json is not compiled in.
func loadEmbeddedIntoMerged() {
if len(embeddedMetaJSON) == 0 {
return
}
var reg MergedRegistry
if err := json.Unmarshal(embeddedMetaJSON, &reg); err != nil {
return
}
embeddedVersion = reg.Version
overlayMergedServices(&reg)
}
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
func rebuildProjectList() {
mergedProjectList = make([]string, 0, len(mergedServices))
for name := range mergedServices {
mergedProjectList = append(mergedProjectList, name)
}
sort.Strings(mergedProjectList)
}
var cachedAllScopes map[string][]string
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
@@ -158,11 +226,7 @@ func CollectAllScopesFromMeta(identity string) []string {
// It returns data from the merged registry (embedded + cached remote overlay).
func LoadFromMeta(project string) map[string]interface{} {
Init()
svc, ok := typedServiceByName(project)
if !ok {
return nil
}
return ServiceToMap(svc)
return mergedServices[project]
}
// ListFromMetaProjects lists available service project names (sorted).
@@ -170,7 +234,7 @@ func LoadFromMeta(project string) map[string]interface{} {
//go:noinline
func ListFromMetaProjects() []string {
Init()
return typedServiceNames()
return mergedProjectList
}
// DefaultScopeScore is the score assigned to scopes not in the priorities table.

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import "embed"
//go:embed meta_data*.json
var metaFS embed.FS
//go:embed meta_data_default.json
var embeddedMetaDataDefaultJSON []byte
func init() {
if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 {
embeddedMetaJSON = data
} else {
embeddedMetaJSON = embeddedMetaDataDefaultJSON
}
}

View File

@@ -0,0 +1 @@
{"version":"0.0.0","services":[]}

View File

@@ -1,99 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package metaschema defines the typed shape of the command-spec registry
// (meta_data.json). The embedded baseline is emitted as static Go data in
// package metastatic (no runtime JSON parse, no startup allocation); the remote
// overlay is decoded into these same types at runtime.
//
// All container fields are slices (never maps): a package-level slice literal is
// laid out in the binary's data section and costs zero heap allocation at
// startup, whereas a map literal builds an hmap at init time. Map keys from the
// JSON (resource/method/field names) are preserved in the Name field.
package metaschema
// Registry is the top level of meta_data.json: {version, services:[...]}.
type Registry struct {
Version string
Services []Service
}
// Service is one API domain (e.g. "im", "calendar").
type Service struct {
Name string
Version string
Title string
Description string
ServicePath string
Resources []Resource // JSON "resources" map, keyed by Resource.Name
}
// Resource groups methods under a service (e.g. "messages").
type Resource struct {
Name string
Methods []Method // JSON "methods" map, keyed by Method.Name
}
// Method is a single API call.
type Method struct {
Name string // JSON map key
ID string
Path string
HTTPMethod string
Description string
Risk string
DocURL string
Danger bool
Scopes []string
AccessTokens []string
ParameterOrder []string
RequiredScopes []string
Parameters []Field // JSON "parameters" map, keyed by Field.Name
RequestBody []Field // JSON "requestBody" map
ResponseBody []Field // JSON "responseBody" map
Affordance *Affordance // optional AI-facing usage overlay; nil on most methods
}
// Field is one parameter / request-body / response-body entry. Nested object
// fields recurse via Properties.
type Field struct {
Name string // JSON map key
Type string
Location string
Description string
Default string
Example string
EnumName string
Min string
Max string
Ref string
Required bool
Options []Option
Enum []string
Annotations []string
Properties []Field
}
// Option is one allowed value for a field with an enum-like option list.
type Option struct {
Value string
Description string
}
// Affordance is the optional AI-facing usage overlay for a method, surfaced in
// the schema envelope as _meta.affordance. Absent (nil) on most methods; it is
// authored upstream in registry-config.yaml and merged into meta_data.json.
type Affordance struct {
UseWhen []string
DoNotUseWhen []string
Prerequisites []string
Examples []AffordanceExample
Related []string
}
// AffordanceExample is one ready-to-run example: a one-line description plus a
// complete lark-cli command string.
type AffordanceExample struct {
Description string
Command string
}

View File

@@ -1,255 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build ignore
// Command gen reads internal/registry/meta_data.json and emits
// meta_data_gen.go: the embedded command spec as a single static
// metaschema.Registry literal (zero runtime JSON parse, zero startup heap
// allocation). Run via: go run internal/registry/metastatic/gen.go
//
// Maps in the JSON (resources/methods/fields) are emitted as slices sorted by
// key so generation is deterministic.
package main
import (
"encoding/json"
"fmt"
"go/format"
"os"
"sort"
"strings"
)
const (
inPath = "internal/registry/meta_data.json"
outPath = "internal/registry/metastatic/meta_data_gen.go"
)
func gs(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
func gb(m map[string]any, k string) bool {
if v, ok := m[k].(bool); ok {
return v
}
return false
}
func gss(m map[string]any, k string) []string {
raw, _ := m[k].([]any)
out := make([]string, 0, len(raw))
for _, e := range raw {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func gm(m map[string]any, k string) map[string]any {
if v, ok := m[k].(map[string]any); ok {
return v
}
return nil
}
func sortedKeys(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func emitStrSlice(b *strings.Builder, name string, vs []string) {
if len(vs) == 0 {
return
}
fmt.Fprintf(b, "%s: []string{", name)
for _, v := range vs {
fmt.Fprintf(b, "%q, ", v)
}
b.WriteString("},\n")
}
func emitOptions(b *strings.Builder, raw []any) {
if len(raw) == 0 {
return
}
b.WriteString("Options: []metaschema.Option{")
for _, e := range raw {
o, _ := e.(map[string]any)
fmt.Fprintf(b, "{Value: %q, Description: %q}, ", gs(o, "value"), gs(o, "description"))
}
b.WriteString("},\n")
}
// emitFields emits a metaschema.Field slice from a JSON map[fieldName]fieldSpec.
func emitFields(b *strings.Builder, label string, fm map[string]any) {
if len(fm) == 0 {
return
}
fmt.Fprintf(b, "%s: []metaschema.Field{\n", label)
for _, name := range sortedKeys(fm) {
f, _ := fm[name].(map[string]any)
if f == nil {
continue
}
b.WriteString("{")
fmt.Fprintf(b, "Name: %q, ", name)
for _, kv := range []struct{ k, field string }{
{"type", "Type"}, {"location", "Location"}, {"description", "Description"},
{"default", "Default"}, {"example", "Example"}, {"enumName", "EnumName"},
{"min", "Min"}, {"max", "Max"}, {"ref", "Ref"},
} {
if v := gs(f, kv.k); v != "" {
fmt.Fprintf(b, "%s: %q, ", kv.field, v)
}
}
if gb(f, "required") {
b.WriteString("Required: true, ")
}
emitStrSlice(b, "Enum", gss(f, "enum"))
emitStrSlice(b, "Annotations", gss(f, "annotations"))
if opts, ok := f["options"].([]any); ok {
emitOptions(b, opts)
}
if props := gm(f, "properties"); props != nil {
emitFields(b, "Properties", props)
}
b.WriteString("},\n")
}
b.WriteString("},\n")
}
// emitAffordance emits a metaschema.Affordance literal from a method's
// "affordance" JSON object, or nothing when absent/empty.
func emitAffordance(b *strings.Builder, raw map[string]any) {
if raw == nil {
return
}
useWhen := gss(raw, "use_when")
doNot := gss(raw, "do_not_use_when")
prereq := gss(raw, "prerequisites")
related := gss(raw, "related")
examples, _ := raw["examples"].([]any)
if len(useWhen) == 0 && len(doNot) == 0 && len(prereq) == 0 && len(related) == 0 && len(examples) == 0 {
return
}
b.WriteString("Affordance: &metaschema.Affordance{")
emitStrSlice(b, "UseWhen", useWhen)
emitStrSlice(b, "DoNotUseWhen", doNot)
emitStrSlice(b, "Prerequisites", prereq)
if len(examples) > 0 {
b.WriteString("Examples: []metaschema.AffordanceExample{")
for _, e := range examples {
ex, _ := e.(map[string]any)
fmt.Fprintf(b, "{Description: %q, Command: %q}, ", gs(ex, "description"), gs(ex, "command"))
}
b.WriteString("},\n")
}
emitStrSlice(b, "Related", related)
b.WriteString("},\n")
}
func emitMethods(b *strings.Builder, mm map[string]any) {
b.WriteString("Methods: []metaschema.Method{\n")
for _, name := range sortedKeys(mm) {
m, _ := mm[name].(map[string]any)
if m == nil {
continue
}
b.WriteString("{")
fmt.Fprintf(b, "Name: %q, ID: %q, Path: %q, HTTPMethod: %q, Description: %q, ",
name, gs(m, "id"), gs(m, "path"), gs(m, "httpMethod"), gs(m, "description"))
if v := gs(m, "risk"); v != "" {
fmt.Fprintf(b, "Risk: %q, ", v)
}
if v := gs(m, "docUrl"); v != "" {
fmt.Fprintf(b, "DocURL: %q, ", v)
}
if gb(m, "danger") {
b.WriteString("Danger: true, ")
}
b.WriteString("\n")
emitStrSlice(b, "Scopes", gss(m, "scopes"))
emitStrSlice(b, "AccessTokens", gss(m, "accessTokens"))
emitStrSlice(b, "ParameterOrder", gss(m, "parameterOrder"))
emitStrSlice(b, "RequiredScopes", gss(m, "requiredScopes"))
emitFields(b, "Parameters", gm(m, "parameters"))
emitFields(b, "RequestBody", gm(m, "requestBody"))
emitFields(b, "ResponseBody", gm(m, "responseBody"))
emitAffordance(b, gm(m, "affordance"))
b.WriteString("},\n")
}
b.WriteString("},\n")
}
func main() {
data, err := os.ReadFile(inPath)
if err != nil {
fmt.Fprintln(os.Stderr, "read:", err)
os.Exit(1)
}
var reg map[string]any
if err := json.Unmarshal(data, &reg); err != nil {
fmt.Fprintln(os.Stderr, "unmarshal:", err)
os.Exit(1)
}
var b strings.Builder
b.WriteString("// Code generated from meta_data.json by gen.go. DO NOT EDIT.\n")
b.WriteString("// Gitignored; produced at build time by `make fetch_meta`.\n\n")
b.WriteString("package metastatic\n\n")
b.WriteString("import \"github.com/larksuite/cli/internal/registry/metaschema\"\n\n")
b.WriteString("// registryData holds the command spec as static Go data. It is a\n")
b.WriteString("// package-level var, so its backing arrays live in the binary's static\n")
b.WriteString("// section (zero heap alloc on read). init() wires it into the Registry\n")
b.WriteString("// declared by stub.go with a single struct-header copy. No build tag is\n")
b.WriteString("// needed: when this generated file is absent (fresh checkout) stub.go's\n")
b.WriteString("// empty Registry stands alone; when present, init() augments it.\n")
b.WriteString("var registryData = metaschema.Registry{\n")
fmt.Fprintf(&b, "Version: %q,\n", gs(reg, "version"))
b.WriteString("Services: []metaschema.Service{\n")
svcs, _ := reg["services"].([]any)
for _, sv := range svcs {
s, _ := sv.(map[string]any)
if s == nil {
continue
}
b.WriteString("{")
fmt.Fprintf(&b, "Name: %q, Version: %q, Title: %q, Description: %q, ServicePath: %q,\n",
gs(s, "name"), gs(s, "version"), gs(s, "title"), gs(s, "description"), gs(s, "servicePath"))
b.WriteString("Resources: []metaschema.Resource{\n")
res := gm(s, "resources")
for _, rname := range sortedKeys(res) {
r, _ := res[rname].(map[string]any)
if r == nil {
continue
}
fmt.Fprintf(&b, "{Name: %q,\n", rname)
emitMethods(&b, gm(r, "methods"))
b.WriteString("},\n")
}
b.WriteString("},\n") // Resources
b.WriteString("},\n") // Service
}
b.WriteString("},\n") // Services
b.WriteString("}\n\n") // registryData literal
b.WriteString("func init() { Registry = registryData }\n")
src, err := format.Source([]byte(b.String()))
if err != nil {
// Write unformatted for debugging, then fail.
_ = os.WriteFile(outPath+".broken", []byte(b.String()), 0644)
fmt.Fprintln(os.Stderr, "gofmt:", err)
os.Exit(1)
}
if err := os.WriteFile(outPath, src, 0644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d services, %d bytes)\n", outPath, len(svcs), len(src))
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package metastatic
import "github.com/larksuite/cli/internal/registry/metaschema"
// Registry is the command spec as static Go data. It is declared here (zero
// value) so the package always compiles, and populated by meta_data_gen.go's
// init() when that generated file is present. On a fresh checkout the generated
// file is absent — it is gitignored and produced at build time by
// `make gen_meta` — so Registry stays empty. This keeps the "heavy spec is
// never committed, only generated" model, now without a build tag: the
// generated file augments this one rather than replacing it under a tag.
var Registry = metaschema.Registry{}

View File

@@ -1,90 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Validation for the static-meta registry: the generated metastatic.Registry is
// the sole embedded baseline (no JSON parsed at runtime), and a deep read of it
// allocates nothing. The data is generated from meta_data.json at build time
// (`make fetch_meta`) and is gitignored, so these tests skip on a bare checkout
// where it has not been generated yet.
package registry
import (
"testing"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/registry/metastatic"
)
func countFieldsStatic(fs []metaschema.Field) int {
n := 0
for _, f := range fs {
n++
n += countFieldsStatic(f.Properties)
}
return n
}
func countStatic() (svc, res, meth, fld int) {
svc = len(metastatic.Registry.Services)
for _, s := range metastatic.Registry.Services {
for _, r := range s.Resources {
res++
for _, m := range r.Methods {
meth++
fld += countFieldsStatic(m.Parameters) + countFieldsStatic(m.RequestBody) + countFieldsStatic(m.ResponseBody)
}
}
}
return
}
// TestStaticRegistryPopulated checks the generated registry carries data. It
// skips on a bare checkout where meta_data_gen.go has not been generated yet.
func TestStaticRegistryPopulated(t *testing.T) {
if len(metastatic.Registry.Services) == 0 {
t.Skip("static registry empty; run `make fetch_meta` to generate it")
}
svc, res, meth, fld := countStatic()
t.Logf("static: services=%d resources=%d methods=%d fields=%d", svc, res, meth, fld)
if svc == 0 || res == 0 || meth == 0 || fld == 0 {
t.Fatalf("static registry incomplete: svc=%d res=%d meth=%d fld=%d", svc, res, meth, fld)
}
if metastatic.Registry.Version == "" {
t.Error("static registry has empty Version")
}
}
var sinkInt int
// --- zero-alloc: a deep read of the static registry must allocate nothing ---
func deepReadStatic() int {
n := 0
for _, s := range metastatic.Registry.Services {
n += len(s.Name)
for _, r := range s.Resources {
for _, m := range r.Methods {
n += len(m.ID) + len(m.Scopes) + countFieldsStatic(m.Parameters) + countFieldsStatic(m.ResponseBody)
}
}
}
return n
}
func TestStaticReadZeroAlloc(t *testing.T) {
if len(metastatic.Registry.Services) == 0 {
t.Skip("static registry empty; run `make fetch_meta` to generate it")
}
avg := testing.AllocsPerRun(50, func() { sinkInt = deepReadStatic() })
t.Logf("static deep-read: %.1f allocs/op", avg)
if avg > 0 {
t.Errorf("static read allocates %.1f/op, want 0 (data should be in the binary, not heap)", avg)
}
}
func BenchmarkReadStaticRegistry(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkInt = deepReadStatic()
}
}

View File

@@ -147,6 +147,22 @@ func saveCacheMeta(meta CacheMeta) error {
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
}
func loadCachedMerged() (*MergedRegistry, error) {
path := cachePath()
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
var reg MergedRegistry
if err := json.Unmarshal(data, &reg); err != nil {
// Cache corrupted — remove it so next run triggers a fresh fetch
vfs.Remove(path)
vfs.Remove(cacheMetaPath())
return nil, err
}
return &reg, nil
}
func saveCachedMerged(data []byte, meta CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
@@ -237,7 +253,7 @@ func doSyncFetch() {
Brand: string(configuredBrand),
}
_ = saveCachedMerged(data, meta)
_ = loadCachedTyped()
overlayMergedServices(reg)
}
// --- background refresh ---
@@ -292,3 +308,15 @@ func shouldRefresh(meta CacheMeta) bool {
}
return time.Since(time.Unix(meta.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 == "" {
continue
}
mergedServices[name] = svc
}
}

View File

@@ -15,8 +15,6 @@ import (
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/registry/metastatic"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
@@ -32,7 +30,8 @@ func resetInit() {
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
resetTyped()
mergedServices = make(map[string]map[string]interface{})
mergedProjectList = nil
embeddedVersion = ""
cachedAllScopes = nil
cachedScopePriorities = nil
@@ -56,10 +55,16 @@ func TestResetInitClearsEmbeddedVersion(t *testing.T) {
}
}
// hasEmbeddedServices returns true if the static registry has services compiled
// in (generated from meta_data.json at build time).
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
func hasEmbeddedServices() bool {
return len(metastatic.Registry.Services) > 0
if len(embeddedMetaJSON) == 0 {
return false
}
var reg MergedRegistry
if err := json.Unmarshal(embeddedMetaJSON, &reg); err != nil {
return false
}
return len(reg.Services) > 0
}
// testRegistry returns a minimal MergedRegistry with one service.
@@ -297,36 +302,50 @@ func TestMetaTTL(t *testing.T) {
}
}
func TestRemoteOverlayTyped(t *testing.T) {
func TestOverlayMergedServices(t *testing.T) {
resetInit()
setRemoteOverrides([]metaschema.Service{
{Name: "existing", Version: "v2"},
{Name: "brand_new", Version: "v1"},
})
mergedServices = make(map[string]map[string]interface{})
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
// override present
if s, ok := typedServiceByName("existing"); !ok || s.Version != "v2" {
t.Errorf("expected existing override v2, got %+v ok=%v", s, ok)
reg := &MergedRegistry{
Services: []map[string]interface{}{
{"name": "existing", "version": "v2"},
{"name": "brand_new", "version": "v1"},
},
}
// new service added
if _, ok := typedServiceByName("brand_new"); !ok {
overlayMergedServices(reg)
// existing should be overridden
if v := mergedServices["existing"]["version"].(string); v != "v2" {
t.Errorf("expected existing to be overridden to v2, got %s", v)
}
// brand_new should be added
if _, ok := mergedServices["brand_new"]; !ok {
t.Error("expected brand_new to be added")
}
}
func TestRemoteOverlayDoesNotPolluteFollowingInit(t *testing.T) {
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
resetInit()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
const leaked = "test_isolation_overlay_sentinel"
setRemoteOverrides([]metaschema.Service{{Name: leaked, Version: "v1"}})
const leakedExisting = "test_isolation_existing_sentinel"
const leakedOverlay = "test_isolation_overlay_sentinel"
mergedServices = map[string]map[string]interface{}{
leakedExisting: {"name": leakedExisting, "version": "v1"},
}
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
resetInit()
Init()
if spec := LoadFromMeta(leaked); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leaked)
if spec := LoadFromMeta(leakedExisting); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
}
if spec := LoadFromMeta(leakedOverlay); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
}
}
@@ -406,8 +425,8 @@ func TestCorruptedCache_SelfHeals(t *testing.T) {
metaData, _ := json.Marshal(meta)
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
// loadCachedTyped should fail and remove the corrupted files
err := loadCachedTyped()
// loadCachedMerged should fail and remove the corrupted files
_, err := loadCachedMerged()
if err == nil {
t.Fatal("expected error for corrupted cache")
}

View File

@@ -1,579 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"sort"
"sync"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/registry/metastatic"
"github.com/larksuite/cli/internal/vfs"
)
// This file is the typed registry layer for the static-meta migration.
//
// - The embedded baseline is metastatic.Registry: static Go data laid out in
// the binary at compile time (zero startup cost). It is empty on a fresh
// checkout (stub.go) until the generated meta_data_gen.go is produced by
// `make fetch_meta`; no build tag is involved.
// - The remote overlay (~/.lark-cli/cache/remote_meta.json) is still fetched
// and refreshed at runtime, decoded into the same typed shape, and merged
// over the baseline as per-service overrides.
//
// Startup (command-tree build) reads these typed structs directly. Execution-
// path consumers that still expect map[string]interface{} go through
// ServiceToMap, which rebuilds one service's map lazily, on demand — never the
// whole spec at startup.
var (
typedMu sync.RWMutex
remoteOverrides map[string]metaschema.Service // service name -> remote override
typedNamesCache []string
)
// resetTyped clears the typed overlay state (test/teardown helper).
func resetTyped() {
typedMu.Lock()
defer typedMu.Unlock()
remoteOverrides = nil
typedNamesCache = nil
}
// baselineServices returns the embedded baseline service specs: the static
// compile-time data in metastatic.Registry (zero parse, zero alloc). It is
// empty only on a fresh checkout where meta_data_gen.go has not been generated
// yet (see stub.go).
var (
baselineOnce sync.Once
baselineSvcs []metaschema.Service
baselineVer string
)
func loadBaseline() {
baselineOnce.Do(func() {
baselineSvcs = metastatic.Registry.Services
baselineVer = metastatic.Registry.Version
})
}
func baselineServices() []metaschema.Service {
loadBaseline()
return baselineSvcs
}
func baselineVersion() string {
loadBaseline()
return baselineVer
}
// baselineServiceByName returns the embedded baseline service spec by name.
func baselineServiceByName(name string) (metaschema.Service, bool) {
svcs := baselineServices()
for i := range svcs {
if svcs[i].Name == name {
return svcs[i], true
}
}
return metaschema.Service{}, false
}
// typedServiceByName returns the effective typed spec for a service: the remote
// override if present, otherwise the static baseline.
func typedServiceByName(name string) (metaschema.Service, bool) {
typedMu.RLock()
if s, ok := remoteOverrides[name]; ok {
typedMu.RUnlock()
return s, true
}
typedMu.RUnlock()
return baselineServiceByName(name)
}
// typedServiceNames returns all effective service names (baseline + remote
// additions), sorted. Cached until the overlay changes.
func typedServiceNames() []string {
typedMu.RLock()
if typedNamesCache != nil {
out := typedNamesCache
typedMu.RUnlock()
return out
}
typedMu.RUnlock()
seen := make(map[string]bool)
for _, s := range baselineServices() {
seen[s.Name] = true
}
typedMu.RLock()
for name := range remoteOverrides {
seen[name] = true
}
typedMu.RUnlock()
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, n)
}
sort.Strings(names)
typedMu.Lock()
typedNamesCache = names
typedMu.Unlock()
return names
}
// setRemoteOverrides installs the parsed remote overlay (called from Init).
func setRemoteOverrides(svcs []metaschema.Service) {
typedMu.Lock()
defer typedMu.Unlock()
if remoteOverrides == nil {
remoteOverrides = make(map[string]metaschema.Service, len(svcs))
}
for _, s := range svcs {
remoteOverrides[s.Name] = s
}
typedNamesCache = nil
}
// TypedService returns the effective typed spec for a service (remote override
// or static baseline). Public accessor for the command-tree builder.
func TypedService(name string) (metaschema.Service, bool) {
Init()
return typedServiceByName(name)
}
// TypedServices returns all effective service specs, sorted by name. Reading
// these builds nothing on the heap (static data); the remote overlay, if any,
// was allocated once at Init.
func TypedServices() []metaschema.Service {
Init()
names := typedServiceNames()
out := make([]metaschema.Service, 0, len(names))
for _, n := range names {
if s, ok := typedServiceByName(n); ok {
out = append(out, s)
}
}
return out
}
// hasTypedData reports whether any typed spec is available (static baseline or
// remote overlay). False only when the static registry has not been generated
// (fresh checkout) and there is no cache.
func hasTypedData() bool {
if len(baselineServices()) > 0 {
return true
}
typedMu.RLock()
defer typedMu.RUnlock()
return len(remoteOverrides) > 0
}
// loadCachedTyped reads the on-disk remote cache, decodes it into the typed
// shape, and installs it as the remote overlay (typed replacement for the old
// map-based loadCachedMerged + overlay).
func loadCachedTyped() error {
data, err := vfs.ReadFile(cachePath())
if err != nil {
return err
}
var reg wireRegistry
if err := json.Unmarshal(data, &reg); err != nil {
// Cache corrupted — remove it so the next run triggers a fresh fetch.
_ = vfs.Remove(cachePath())
_ = vfs.Remove(cacheMetaPath())
return err
}
svcs := make([]metaschema.Service, 0, len(reg.Services))
for _, ws := range reg.Services {
svcs = append(svcs, wireToService(ws))
}
setRemoteOverrides(svcs)
return nil
}
// --- typed -> map[string]interface{} shim (lazy, per service, execution-path) ---
func strList(ss []string) []interface{} {
if len(ss) == 0 {
return nil
}
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
func fieldToMap(f metaschema.Field) map[string]interface{} {
m := map[string]interface{}{}
put := func(k, v string) {
if v != "" {
m[k] = v
}
}
put("type", f.Type)
put("location", f.Location)
put("description", f.Description)
put("default", f.Default)
put("example", f.Example)
put("enumName", f.EnumName)
put("min", f.Min)
put("max", f.Max)
put("ref", f.Ref)
if f.Required {
m["required"] = true
}
if v := strList(f.Enum); v != nil {
m["enum"] = v
}
if v := strList(f.Annotations); v != nil {
m["annotations"] = v
}
if len(f.Options) > 0 {
opts := make([]interface{}, len(f.Options))
for i, o := range f.Options {
opts[i] = map[string]interface{}{"value": o.Value, "description": o.Description}
}
m["options"] = opts
}
if len(f.Properties) > 0 {
m["properties"] = fieldsToMap(f.Properties)
}
return m
}
func fieldsToMap(fs []metaschema.Field) map[string]interface{} {
if len(fs) == 0 {
return nil
}
m := make(map[string]interface{}, len(fs))
for _, f := range fs {
m[f.Name] = fieldToMap(f)
}
return m
}
// affordanceToMap rebuilds the JSON-shaped affordance object (snake_case keys)
// so the schema assembler's parseAffordance(method["affordance"]) keeps working
// through the typed registry. Returns nil when the overlay carries nothing.
func affordanceToMap(a *metaschema.Affordance) map[string]interface{} {
m := map[string]interface{}{}
if v := strList(a.UseWhen); v != nil {
m["use_when"] = v
}
if v := strList(a.DoNotUseWhen); v != nil {
m["do_not_use_when"] = v
}
if v := strList(a.Prerequisites); v != nil {
m["prerequisites"] = v
}
if len(a.Examples) > 0 {
ex := make([]interface{}, len(a.Examples))
for i, e := range a.Examples {
ex[i] = map[string]interface{}{"description": e.Description, "command": e.Command}
}
m["examples"] = ex
}
if v := strList(a.Related); v != nil {
m["related"] = v
}
if len(m) == 0 {
return nil
}
return m
}
func MethodToMap(mth metaschema.Method) map[string]interface{} {
m := map[string]interface{}{
"id": mth.ID,
"path": mth.Path,
"httpMethod": mth.HTTPMethod,
"description": mth.Description,
}
if mth.Risk != "" {
m["risk"] = mth.Risk
}
if mth.DocURL != "" {
m["docUrl"] = mth.DocURL
}
if mth.Danger {
m["danger"] = true
}
if v := strList(mth.Scopes); v != nil {
m["scopes"] = v
}
if v := strList(mth.AccessTokens); v != nil {
m["accessTokens"] = v
}
if v := strList(mth.ParameterOrder); v != nil {
m["parameterOrder"] = v
}
if v := strList(mth.RequiredScopes); v != nil {
m["requiredScopes"] = v
}
if v := fieldsToMap(mth.Parameters); v != nil {
m["parameters"] = v
}
if v := fieldsToMap(mth.RequestBody); v != nil {
m["requestBody"] = v
}
if v := fieldsToMap(mth.ResponseBody); v != nil {
m["responseBody"] = v
}
if mth.Affordance != nil {
if am := affordanceToMap(mth.Affordance); am != nil {
m["affordance"] = am
}
}
return m
}
// ServiceToMap rebuilds the JSON-shaped map[string]interface{} for one service,
// so execution-path consumers (and method RunE) keep working unchanged.
func ServiceToMap(s metaschema.Service) map[string]interface{} {
resources := make(map[string]interface{}, len(s.Resources))
for _, r := range s.Resources {
methods := make(map[string]interface{}, len(r.Methods))
for _, mth := range r.Methods {
methods[mth.Name] = MethodToMap(mth)
}
resources[r.Name] = map[string]interface{}{"methods": methods}
}
return map[string]interface{}{
"name": s.Name,
"version": s.Version,
"title": s.Title,
"description": s.Description,
"servicePath": s.ServicePath,
"resources": resources,
}
}
// --- map[string]interface{} -> typed (for the map-based wrappers still used by
// tests; production builds from typed directly) ---
func ifaceStrs(v interface{}) []string {
raw, _ := v.([]interface{})
if len(raw) == 0 {
return nil
}
out := make([]string, 0, len(raw))
for _, e := range raw {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func sortedMapKeys(m map[string]interface{}) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func mapToField(name string, m map[string]interface{}) metaschema.Field {
f := metaschema.Field{
Name: name, Type: GetStrFromMap(m, "type"), Location: GetStrFromMap(m, "location"),
Description: GetStrFromMap(m, "description"), Default: GetStrFromMap(m, "default"),
Example: GetStrFromMap(m, "example"), EnumName: GetStrFromMap(m, "enumName"),
Min: GetStrFromMap(m, "min"), Max: GetStrFromMap(m, "max"), Ref: GetStrFromMap(m, "ref"),
Enum: ifaceStrs(m["enum"]), Annotations: ifaceStrs(m["annotations"]),
}
if b, ok := m["required"].(bool); ok {
f.Required = b
}
if opts, ok := m["options"].([]interface{}); ok {
for _, o := range opts {
om, _ := o.(map[string]interface{})
f.Options = append(f.Options, metaschema.Option{Value: GetStrFromMap(om, "value"), Description: GetStrFromMap(om, "description")})
}
}
f.Properties = mapToFields(m["properties"])
return f
}
func mapToFields(v interface{}) []metaschema.Field {
fm, _ := v.(map[string]interface{})
if len(fm) == 0 {
return nil
}
out := make([]metaschema.Field, 0, len(fm))
for _, k := range sortedMapKeys(fm) {
em, _ := fm[k].(map[string]interface{})
out = append(out, mapToField(k, em))
}
return out
}
func MapToMethod(name string, m map[string]interface{}) metaschema.Method {
return metaschema.Method{
Name: name, ID: GetStrFromMap(m, "id"), Path: GetStrFromMap(m, "path"),
HTTPMethod: GetStrFromMap(m, "httpMethod"), Description: GetStrFromMap(m, "description"),
Risk: GetStrFromMap(m, "risk"), DocURL: GetStrFromMap(m, "docUrl"),
Danger: boolFromMap(m, "danger"),
Scopes: ifaceStrs(m["scopes"]),
AccessTokens: ifaceStrs(m["accessTokens"]),
ParameterOrder: ifaceStrs(m["parameterOrder"]),
RequiredScopes: ifaceStrs(m["requiredScopes"]),
Parameters: mapToFields(m["parameters"]),
RequestBody: mapToFields(m["requestBody"]),
ResponseBody: mapToFields(m["responseBody"]),
}
}
func boolFromMap(m map[string]interface{}, k string) bool {
b, _ := m[k].(bool)
return b
}
func MapToResources(v interface{}) []metaschema.Resource {
rm, _ := v.(map[string]interface{})
if len(rm) == 0 {
return nil
}
out := make([]metaschema.Resource, 0, len(rm))
for _, rk := range sortedMapKeys(rm) {
res, _ := rm[rk].(map[string]interface{})
mm, _ := res["methods"].(map[string]interface{})
methods := make([]metaschema.Method, 0, len(mm))
for _, mk := range sortedMapKeys(mm) {
methodMap, _ := mm[mk].(map[string]interface{})
methods = append(methods, MapToMethod(mk, methodMap))
}
out = append(out, metaschema.Resource{Name: rk, Methods: methods})
}
return out
}
// MapToService converts a JSON-shaped service spec (with embedded "resources")
// into the typed form.
func MapToService(spec map[string]interface{}) metaschema.Service {
return metaschema.Service{
Name: GetStrFromMap(spec, "name"), Version: GetStrFromMap(spec, "version"),
Title: GetStrFromMap(spec, "title"), Description: GetStrFromMap(spec, "description"),
ServicePath: GetStrFromMap(spec, "servicePath"), Resources: MapToResources(spec["resources"]),
}
}
// --- remote JSON (wire) -> typed ---
type wireRegistry struct {
Version string `json:"version"`
Services []wireService `json:"services"`
}
type wireService struct {
Name string `json:"name"`
Version string `json:"version"`
Title string `json:"title"`
Description string `json:"description"`
ServicePath string `json:"servicePath"`
Resources map[string]wireResource `json:"resources"`
}
type wireResource struct {
Methods map[string]wireMethod `json:"methods"`
}
type wireMethod struct {
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"`
Scopes []string `json:"scopes"`
AccessTokens []string `json:"accessTokens"`
ParameterOrder []string `json:"parameterOrder"`
RequiredScopes []string `json:"requiredScopes"`
Parameters map[string]wireField `json:"parameters"`
RequestBody map[string]wireField `json:"requestBody"`
ResponseBody map[string]wireField `json:"responseBody"`
}
type wireField struct {
Type string `json:"type"`
Location string `json:"location"`
Description string `json:"description"`
Default string `json:"default"`
Example string `json:"example"`
EnumName string `json:"enumName"`
Min string `json:"min"`
Max string `json:"max"`
Ref string `json:"ref"`
Required bool `json:"required"`
Options []metaschema.Option `json:"options"`
Enum []string `json:"enum"`
Annotations []string `json:"annotations"`
Properties map[string]wireField `json:"properties"`
}
func sortedFieldKeys(m map[string]wireField) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func wireFields(m map[string]wireField) []metaschema.Field {
if len(m) == 0 {
return nil
}
out := make([]metaschema.Field, 0, len(m))
for _, name := range sortedFieldKeys(m) {
wf := m[name]
out = append(out, metaschema.Field{
Name: name, Type: wf.Type, Location: wf.Location, Description: wf.Description,
Default: wf.Default, Example: wf.Example, EnumName: wf.EnumName,
Min: wf.Min, Max: wf.Max, Ref: wf.Ref, Required: wf.Required,
Options: wf.Options, Enum: wf.Enum, Annotations: wf.Annotations,
Properties: wireFields(wf.Properties),
})
}
return out
}
func wireToService(ws wireService) metaschema.Service {
resKeys := make([]string, 0, len(ws.Resources))
for k := range ws.Resources {
resKeys = append(resKeys, k)
}
sort.Strings(resKeys)
resources := make([]metaschema.Resource, 0, len(resKeys))
for _, rk := range resKeys {
wr := ws.Resources[rk]
methKeys := make([]string, 0, len(wr.Methods))
for k := range wr.Methods {
methKeys = append(methKeys, k)
}
sort.Strings(methKeys)
methods := make([]metaschema.Method, 0, len(methKeys))
for _, mk := range methKeys {
wm := wr.Methods[mk]
methods = append(methods, metaschema.Method{
Name: mk, ID: wm.ID, Path: wm.Path, HTTPMethod: wm.HTTPMethod,
Description: wm.Description, Risk: wm.Risk, DocURL: wm.DocURL, Danger: wm.Danger,
Scopes: wm.Scopes, AccessTokens: wm.AccessTokens,
ParameterOrder: wm.ParameterOrder, RequiredScopes: wm.RequiredScopes,
Parameters: wireFields(wm.Parameters), RequestBody: wireFields(wm.RequestBody),
ResponseBody: wireFields(wm.ResponseBody),
})
}
resources = append(resources, metaschema.Resource{Name: rk, Methods: methods})
}
return metaschema.Service{
Name: ws.Name, Version: ws.Version, Title: ws.Title,
Description: ws.Description, ServicePath: ws.ServicePath, Resources: resources,
}
}

View File

@@ -4,14 +4,290 @@
package schema
import (
"bytes"
"encoding/json"
"sort"
"strconv"
"sync"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/registry"
)
// MethodKeyOrder records the natural meta_data.json key order for one method's
// parameters / requestBody / responseBody. Nested object key orders are stored
// under NestedKeys, keyed by dotted path from the method root
// (e.g. "responseBody.items.properties").
type MethodKeyOrder struct {
Parameters []string
RequestBody []string
ResponseBody []string
NestedKeys map[string][]string
}
var (
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
keyOrderInitOnce sync.Once
)
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
// or nil if the method is not in the embedded data (e.g. remote-cached).
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
keyOrderInitOnce.Do(buildKeyOrderIndex)
if keyOrderIndex == nil {
return nil
}
dotted := dottedPath(service, resourcePath, method)
return keyOrderIndex[dotted]
}
func dottedPath(service string, resourcePath []string, method string) string {
var buf bytes.Buffer
buf.WriteString(service)
for _, r := range resourcePath {
buf.WriteByte('.')
buf.WriteString(r)
}
buf.WriteByte('.')
buf.WriteString(method)
return buf.String()
}
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
// and recording each map's key insertion order via json.Decoder.Token().
func buildKeyOrderIndex() {
raw := registry.EmbeddedMetaJSON()
if len(raw) == 0 {
return
}
keyOrderIndex = make(map[string]*MethodKeyOrder)
dec := json.NewDecoder(bytes.NewReader(raw))
// Top-level: { "services": [...], "version": "..." }
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
if key != "services" {
skipValue(dec)
continue
}
if !expectDelim(dec, '[') {
return
}
for dec.More() {
parseService(dec)
}
// closing ]
_, _ = dec.Token()
}
}
// parseService consumes one service object inside services[].
// meta_data.json may emit "resources" before "name", so we first capture both
// raw fields, then walk resources with the resolved service name.
func parseService(dec *json.Decoder) {
if !expectDelim(dec, '{') {
return
}
var serviceName string
var resourcesRaw json.RawMessage
for dec.More() {
key, _ := readKey(dec)
switch key {
case "name":
tok, _ := dec.Token()
if s, ok := tok.(string); ok {
serviceName = s
}
case "resources":
if err := dec.Decode(&resourcesRaw); err != nil {
skipValue(dec)
}
default:
skipValue(dec)
}
}
_, _ = dec.Token() // closing }
if serviceName != "" && len(resourcesRaw) > 0 {
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
parseResources(subDec, serviceName, nil)
}
}
// parseResources walks a resources map (resName -> resource object).
// resourcePath is the accumulated path of parent resources (for nested resources).
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
resName, _ := readKey(dec)
parseResourceObj(dec, service, append(resourcePath, resName))
}
_, _ = dec.Token()
}
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
// recurse into nested resources via "resources" key if present.
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "methods":
parseMethods(dec, service, resourcePath)
case "resources":
parseResources(dec, service, resourcePath)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
}
// parseMethods consumes the methods map (methodName -> method object).
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
methodName, _ := readKey(dec)
mko := parseMethod(dec)
dotted := dottedPath(service, resourcePath, methodName)
keyOrderIndex[dotted] = mko
}
_, _ = dec.Token()
}
// parseMethod consumes one method object and records key orders.
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
if !expectDelim(dec, '{') {
return mko
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "parameters":
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
case "requestBody":
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
case "responseBody":
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
return mko
}
// recordObjectKeysRecursive consumes an object and records the top-level key
// order. It also recurses into each child's "properties" submap, recording
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
// in order.
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
if !expectDelim(dec, '{') {
return nil
}
var order []string
for dec.More() {
key, _ := readKey(dec)
order = append(order, key)
// Each child value is itself an object; we want its nested "properties" order if present.
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
}
_, _ = dec.Token()
if prefix != "" && len(order) > 0 {
nestedKeys[prefix] = order
}
return order
}
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
// if it contains "properties": {...}, recursively records that submap's order.
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
tok, err := dec.Token()
if err != nil {
return
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
// Not an object — skip the rest of the value
skipValueAfterToken(dec, tok)
return
}
for dec.More() {
fieldKey, _ := readKey(dec)
if fieldKey == "properties" {
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
} else {
skipValue(dec)
}
}
_, _ = dec.Token()
}
// --- json.Decoder helpers ---
func expectDelim(dec *json.Decoder, want json.Delim) bool {
tok, err := dec.Token()
if err != nil {
return false
}
delim, ok := tok.(json.Delim)
return ok && delim == want
}
func readKey(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
s, _ := tok.(string)
return s, nil
}
// skipValue consumes the next complete value (scalar, object, or array).
func skipValue(dec *json.Decoder) {
tok, err := dec.Token()
if err != nil {
return
}
skipValueAfterToken(dec, tok)
}
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
delim, ok := tok.(json.Delim)
if !ok {
return
}
// We started inside a container of type `delim` ({ or [) and must eat
// tokens until that container closes, tracking nested containers of any
// kind. depth counts how many open containers we are currently inside.
_ = delim
depth := 1
for depth > 0 {
t, err := dec.Token()
if err != nil {
return
}
if d, ok := t.(json.Delim); ok {
switch d {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
}
// coerceLiteral converts a meta_data literal (default / enum / example) to
// the JSON Schema type declared by the field (integer/number/boolean/string).
// meta_data stores every literal as a string, so without coercion an
@@ -225,6 +501,10 @@ func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedP
return op, required
}
// currentMethodOrder is the per-method key-order context used by orderedKeys.
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
var currentMethodOrder *MethodKeyOrder
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
// or carries no populated subfields.
@@ -331,6 +611,8 @@ func buildMeta(method map[string]interface{}) *Meta {
// The params / data wrapping mirrors the CLI's actual flag layout:
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
// can pluck inputSchema.properties.params and pass it verbatim to --params.
//
// Caller must set currentMethodOrder for property-order preservation.
func buildInputSchema(method map[string]interface{}) *InputSchema {
is := &InputSchema{
Type: "object",
@@ -456,11 +738,27 @@ func buildOutputSchema(method map[string]interface{}) *OutputSchema {
return os
}
// assembleMu serializes AssembleEnvelope calls so that the package-level
// currentMethodOrder pointer is safe for concurrent callers.
var assembleMu sync.Mutex
// AssembleEnvelope is the main entry point: takes a service / resource path /
// method name plus its meta_data spec, and produces a fully assembled MCP
// envelope. Output is fully determined by inputs (same arguments → same
// envelope).
// envelope), but assembly briefly publishes the per-method key-order context
// through the package-level currentMethodOrder so orderedKeys can reach it
// without threading it through every helper. assembleMu serializes that
// publish, which is why concurrent callers are still safe — they queue
// rather than run in parallel.
//
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
assembleMu.Lock()
defer assembleMu.Unlock()
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
defer func() { currentMethodOrder = nil }()
name := serviceName
for _, r := range resourcePath {
name += " " + r
@@ -538,10 +836,35 @@ func walkMethods(resources map[string]interface{}, parentPath []string,
}
}
// orderedKeys returns the keys of raw in alphabetical order. Field display
// order is not preserved: the schema envelope is consumed as a JSON Schema (MCP
// tool spec), where object property order carries no meaning.
func orderedKeys(raw map[string]interface{}, _ string) []string {
// orderedKeys returns the keys of raw in their meta_data natural order if
// the current per-method key-order context has them recorded; otherwise
// alphabetical fallback.
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
if currentMethodOrder != nil && nestedPath != "" {
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
// Filter to keys that actually exist in raw (defensive)
out := make([]string, 0, len(order))
seen := make(map[string]bool)
for _, k := range order {
if _, ok := raw[k]; ok {
out = append(out, k)
seen[k] = true
}
}
// Append any keys present in raw but missing from order (defensive),
// alphabetically for determinism.
var extra []string
for k := range raw {
if !seen[k] {
extra = append(extra, k)
}
}
sort.Strings(extra)
out = append(out, extra...)
return out
}
}
// Fallback: alphabetical
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)

View File

@@ -7,12 +7,10 @@ import (
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/registry/metaschema"
)
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
@@ -37,6 +35,58 @@ 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
@@ -238,6 +288,9 @@ 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)
@@ -260,15 +313,16 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
// tool spec) where object property order carries no meaning.
if !sort.StringsAreSorted(params.Properties.Order) {
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
}
}
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)
@@ -328,6 +382,9 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
},
},
}
currentMethodOrder = nil
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// yes lives at inputSchema.properties.yes (sibling of params/data)
@@ -356,6 +413,9 @@ 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 {
@@ -365,6 +425,9 @@ 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)
@@ -550,45 +613,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// TestBuildMeta_AffordanceThroughTypedRegistry guards the static-registry path:
// a method's affordance must survive metaschema.Method -> registry.MethodToMap
// -> buildMeta, so `schema --format json` keeps emitting _meta.affordance after
// the embedded-JSON-to-typed-registry migration. Without typed-side support the
// overlay is silently stripped whenever meta_data.json carries affordance.
func TestBuildMeta_AffordanceThroughTypedRegistry(t *testing.T) {
mth := metaschema.Method{
Name: "primary",
Affordance: &metaschema.Affordance{
UseWhen: []string{"用户想拿到自己默认日历的 ID"},
DoNotUseWhen: []string{"已经知道某个具体日历的 ID"},
Prerequisites: []string{"user 身份登录"},
Examples: []metaschema.AffordanceExample{
{Description: "取主日历", Command: "lark-cli calendar calendars primary"},
},
Related: []string{"calendars.list", "calendars.get"},
},
}
method := registry.MethodToMap(mth)
m := buildMeta(method)
if m.Affordance == nil {
t.Fatal("affordance dropped through the typed registry (MethodToMap -> buildMeta)")
}
a := m.Affordance
if len(a.UseWhen) != 1 || a.UseWhen[0] != "用户想拿到自己默认日历的 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.DoNotUseWhen) != 1 || len(a.Prerequisites) != 1 {
t.Errorf("DoNotUseWhen=%v Prerequisites=%v", a.DoNotUseWhen, a.Prerequisites)
}
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) != 2 {
t.Errorf("Related = %v", a.Related)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
@@ -610,6 +634,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)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)

View File

@@ -83,13 +83,9 @@ type AffordanceCase struct {
Command string `json:"command"`
}
// OrderedProps is map[string]Property that emits its keys in Order on
// MarshalJSON. Order is now populated alphabetically (see orderedKeys): the
// schema envelope is an MCP tool spec / JSON Schema, where object property
// order carries no meaning. The machinery that once preserved meta_data.json's
// natural field order was removed with the static-registry migration; Order is
// retained so MarshalJSON has one stable key sequence (and callers that leave
// it empty fall back to alphabetical over Map).
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
// It is used wherever JSON output must reflect meta_data.json's natural field
// order rather than Go's default alphabetical map encoding.
type OrderedProps struct {
Order []string
Map map[string]Property

View File

@@ -17,13 +17,20 @@ import (
var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -18,14 +18,20 @@ import (
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
"shortcuts/wiki/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -691,7 +691,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -907,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
@@ -944,11 +944,19 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"HandleApiResult",
}
paths := []string{
"shortcuts/calendar/calendar_create.go",
"shortcuts/contact/contact_search_user.go",
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
"shortcuts/wiki/wiki_node_get.go",
}
for _, path := range paths {
for _, helper := range helpers {
@@ -997,6 +1005,34 @@ func boom() {
}
}
func TestCheckNoLegacyCommonHelperCall_CoversCCMPathsWithAliasAndFunctionValue(t *testing.T) {
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/wiki/wiki_node_get.go",
}
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
for _, path := range paths {
t.Run(path, func(t *testing.T) {
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on %s, got %d: %+v", path, len(v), v)
}
})
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact
@@ -1006,7 +1042,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.48",
"version": "1.0.49",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -6,7 +6,6 @@ OUT_DIR="$ROOT_DIR/.pkg-pr-new"
cd "$ROOT_DIR"
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go).
python3 scripts/fetch_meta.py
rm -rf "$OUT_DIR"

View File

@@ -63,19 +63,6 @@ def fetch_remote(brand):
return data
def run_gen():
"""Regenerate the static Go registry (metastatic/meta_data_gen.go) from
meta_data.json. Run after every fetch so any caller that fetches also
produces the sole build-time source of the embedded command tree — no build
tag, no JSON embedded in the binary. Output is gitignored."""
print("fetch-meta: generating static Go registry (metastatic/meta_data_gen.go)", file=sys.stderr)
subprocess.run(
["go", "run", "internal/registry/metastatic/gen.go"],
cwd=ROOT,
check=True,
)
def main():
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
@@ -84,29 +71,27 @@ def main():
help="force refresh from remote even if local file exists")
args = parser.parse_args()
have_valid = False
if os.path.isfile(OUT_PATH) and not args.force:
try:
with open(OUT_PATH, "r", encoding="utf-8") as fp:
local = json.load(fp)
have_valid = bool(local.get("services"))
except (OSError, json.JSONDecodeError):
have_valid = False
if os.path.exists(OUT_PATH) and not args.force:
if os.path.isfile(OUT_PATH):
try:
with open(OUT_PATH, "r", encoding="utf-8") as fp:
local = json.load(fp)
if local.get("services"):
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
return
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
except (OSError, json.JSONDecodeError):
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
else:
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
if have_valid:
print(f"fetch-meta: {OUT_PATH} already exists, skipping fetch (use --force to re-fetch)", file=sys.stderr)
else:
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
# Always (re)generate the static Go registry so every fetch also produces
# the embedded command tree — the build-time replacement for the old
# embedded meta_data.json.
run_gen()
with open(OUT_PATH, "w") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")
if __name__ == "__main__":

View File

@@ -676,30 +676,10 @@ func WrapInputStatErrorTyped(err error, readMsg ...string) error {
WithCause(err)
}
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).
//
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return output.ErrValidation("unsafe output path: %s", err)
case errors.As(err, &me):
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
default:
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
}
}
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
// "internal" wire type: call sites migrating from a custom category
// (e.g. "io", "api_error") change their envelope's type field.
// Non-path failures always emit the canonical "internal" wire type: call sites
// migrating from a custom legacy category (e.g. "io", "api_error") change
// their envelope's type field.
func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil

View File

@@ -6,24 +6,8 @@ package common
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
)
// ResolveOpenIDs expands the special identifier "me" to the current user's
// open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName is
// used in error messages to point the user at the offending CLI flag.
//
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, output.ErrValidation("%s", msg)
}
return out, nil
}
// ResolveOpenIDsTyped expands the special identifier "me" to the current
// user's open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName names

View File

@@ -17,9 +17,9 @@ func resolveOpenIDsTestRuntime(userOpenID string) *RuntimeContext {
return TestNewRuntimeContext(cmd, cfg)
}
func TestResolveOpenIDs_Empty(t *testing.T) {
func TestResolveOpenIDsTyped_Empty(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDs("--user-ids", nil, rt)
out, err := ResolveOpenIDsTyped("--user-ids", nil, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -28,21 +28,9 @@ func TestResolveOpenIDs_Empty(t *testing.T) {
}
}
func TestResolveOpenIDs_ExpandsMeAndDedups(t *testing.T) {
func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDs("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"ou_self", "ou_a"}
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
t.Fatalf("got %v, want %v", out, want)
}
}
func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDs("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -52,22 +40,11 @@ func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
}
}
func TestResolveOpenIDs_MeWithoutLogin(t *testing.T) {
rt := resolveOpenIDsTestRuntime("")
_, err := ResolveOpenIDs("--user-ids", []string{"me"}, rt)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--user-ids") {
t.Fatalf("error should mention the offending flag name; got: %v", err)
}
}
func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
func TestResolveOpenIDsTyped_DedupIsCaseInsensitive(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
// Same underlying open_id with three case variants — should collapse to
// one entry, preserving the first-occurrence form.
out, err := ResolveOpenIDs("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

View File

@@ -5,8 +5,6 @@ package common
import (
"strings"
"github.com/larksuite/cli/internal/output"
)
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
@@ -42,17 +40,6 @@ func normalizeChatID(input string) (string, string) {
return input, ""
}
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
//
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
func ValidateUserID(input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return userID, nil
}
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
// param names the flag being validated (e.g. "--creator-ids") and is
// recorded on the typed error.

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
)
const contactFanoutRetryHint = "retry the command; if it persists, narrow --queries to a single term to isolate the failing input"
func contactInvalidResponseError(format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}
func contactFanoutErrorSummary(err error) string {
if p, ok := errs.ProblemOf(err); ok {
if p.Code >= 100 && p.Code < 600 {
prefix := fmt.Sprintf("HTTP %d:", p.Code)
body := strings.TrimSpace(strings.TrimPrefix(p.Message, prefix))
msg := fmt.Sprintf("HTTP %d %s", p.Code, http.StatusText(p.Code))
if body != "" {
msg = fmt.Sprintf("%s: %s", msg, contactTruncateError(body, 200))
}
return msg
}
if p.Code != 0 {
return fmt.Sprintf("API %d: %s", p.Code, p.Message)
}
return p.Message
}
return err.Error()
}
// contactFanoutAllFailedError builds the top-level error returned when every
// fanout query fails. It mirrors the representative (first) failure's
// classification — category, subtype, code, log_id, retryable, hint — so the
// exit-code classifier still sees the real signal, while carrying the aggregate
// message. The representative error is copied (never mutated) and kept as the
// cause, so a single-query problem object is not rewritten into an aggregate one.
func contactFanoutAllFailedError(err error, msg string) error {
var (
apiErr *errs.APIError
netErr *errs.NetworkError
intErr *errs.InternalError
)
switch {
case errors.As(err, &apiErr):
c := *apiErr
c.Message = msg
c.Cause = err
return &c
case errors.As(err, &netErr):
c := *netErr
c.Message = msg
c.Cause = err
return &c
case errors.As(err, &intErr):
c := *intErr
c.Message = msg
c.Cause = err
return &c
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithHint(contactFanoutRetryHint).WithCause(err)
}
func contactTruncateError(s string, maxRunes int) string {
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "..."
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestContactFanoutErrorSummary_HTTPStatus(t *testing.T) {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, `HTTP 503: {"reason":"upstream_unavailable"}`).
WithCode(503).
WithRetryable()
got := contactFanoutErrorSummary(err)
if !strings.HasPrefix(got, "HTTP 503 Service Unavailable: ") {
t.Fatalf("summary: got %q", got)
}
if !strings.Contains(got, "upstream_unavailable") {
t.Fatalf("summary should include truncated body details, got %q", got)
}
}
func TestContactInvalidResponseError_TypedInternal(t *testing.T) {
got := contactInvalidResponseError("decode contact response failed")
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T", got)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
}
}
func TestContactFanoutAllFailedError_PreservesTypedProblem(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").
WithCode(99991663).
WithLogID("log-contact-1").
WithRetryable()
got := contactFanoutAllFailedError(err, "all 2 queries failed; first: API 99991663: rate limit (query=\"alice\")")
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T", got)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit {
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
}
if p.Code != 99991663 || p.LogID != "log-contact-1" || !p.Retryable {
t.Fatalf("problem metadata not preserved: %+v", p)
}
if !strings.Contains(p.Message, "all 2 queries failed") {
t.Fatalf("problem message not decorated: %q", p.Message)
}
// The representative error must not be mutated: it stays a single-query
// failure, while the aggregate is a distinct value carrying it as cause.
if err.Message != "rate limit" {
t.Fatalf("representative error message was mutated: %q", err.Message)
}
if !errors.Is(got, err) {
t.Fatalf("aggregate error should keep the representative failure as its cause")
}
}
func TestContactFanoutAllFailedError_UntypedGetsActionableHint(t *testing.T) {
got := contactFanoutAllFailedError(nil, "all 2 queries failed; first: internal error (query=\"alice\")")
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T", got)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeUnknown {
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "narrow --queries") {
t.Fatalf("hint should guide recovery, got %q", p.Hint)
}
}

View File

@@ -28,7 +28,8 @@ var ContactGetUser = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("user-id") == "" && runtime.IsBot() {
return common.FlagErrorf("bot identity cannot get current user info, specify --user-id")
return common.ValidationErrorf("bot identity cannot get current user info, specify --user-id").
WithParam("--user-id")
}
return nil
},
@@ -63,7 +64,7 @@ var ContactGetUser = common.Shortcut{
if userId == "" {
// Current user
data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/authen/v1/user_info", nil, nil)
if err != nil {
return err
}
@@ -87,7 +88,7 @@ var ContactGetUser = common.Shortcut{
if runtime.IsBot() {
// Bot identity: GET /contact/v3/users/:user_id (full profile)
data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
data, err := runtime.CallAPITyped("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
map[string]interface{}{"user_id_type": userIdType}, nil)
if err != nil {
return err
@@ -110,7 +111,7 @@ var ContactGetUser = common.Shortcut{
}
// User identity: POST /contact/v3/users/basic_batch (lightweight)
data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch",
data, err := runtime.CallAPITyped("POST", "/open-apis/contact/v3/users/basic_batch",
map[string]interface{}{"user_id_type": userIdType},
map[string]interface{}{"user_ids": []string{userId}})
if err != nil {

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"bytes"
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestGetUser_BotCurrentUserValidationTyped(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatalf("expected validation error")
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validation.Param != "--user-id" {
t.Fatalf("param: got %q, want --user-id", validation.Param)
}
}
func TestGetUser_DryRunShapes(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{
name: "current user",
args: []string{"+get-user", "--dry-run", "--as", "user"},
want: []string{"GET", "/authen/v1/user_info", "current_user"},
},
{
name: "bot specific user",
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "bot"},
want: []string{"GET", "/contact/v3/users/ou_a", "ou_a", "open_id"},
},
{
name: "user basic batch",
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "user"},
want: []string{"POST", "/contact/v3/users/basic_batch", "ou_a", "open_id"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
if err := mountAndRun(t, ContactGetUser, tc.args, f, stdout); err != nil {
t.Fatalf("dry-run: %v", err)
}
out := stdout.String()
for _, want := range tc.want {
if !bytes.Contains(stdout.Bytes(), []byte(want)) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
})
}
}
func TestGetUser_CurrentUserAPIFailureTyped(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/authen/v1/user_info",
Body: map[string]interface{}{"code": 123456, "msg": "upstream rejected contact request"},
})
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "user"}, f, stdout)
if err == nil {
t.Fatalf("expected API error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Code != 123456 {
t.Fatalf("code: got %d, want 123456", p.Code)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryAPI)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should stay empty on API failure, got %q", stdout.String())
}
}
func TestGetUser_UserBasicBatchUsesTypedAPI(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/contact/v3/users/basic_batch?user_id_type=open_id",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"users": []interface{}{
map[string]interface{}{"user_id": "ou_a", "name": "Alice"},
},
},
},
}
reg.Register(stub)
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--user-id", "ou_a", "--as", "user", "--format", "json"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
if !bytes.Contains(stub.CapturedBody, []byte(`"ou_a"`)) {
t.Fatalf("request body should include user id, got %s", string(stub.CapturedBody))
}
if !bytes.Contains(stdout.Bytes(), []byte(`"user"`)) {
t.Fatalf("stdout should include user object, got %s", stdout.String())
}
}

View File

@@ -15,6 +15,7 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -80,12 +81,6 @@ type searchUserAPIFilter struct {
HasEnterpriseEmail bool `json:"has_enterprise_email,omitempty"`
}
type searchUserAPIEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *searchUserAPIData `json:"data"`
}
type searchUserAPIData struct {
Items []searchUserAPIItem `json:"items"`
HasMore bool `json:"has_more"`
@@ -216,19 +211,17 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
if err != nil {
return err
}
if apiResp.StatusCode != http.StatusOK {
return output.ErrAPI(apiResp.StatusCode, http.StatusText(apiResp.StatusCode), string(apiResp.RawBody))
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
respData, err := decodeSearchUserAPIData(data)
if err != nil {
return err
}
var resp searchUserAPIEnvelope
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response failed", err.Error())
}
if resp.Code != 0 {
return output.ErrAPI(resp.Code, resp.Msg, string(apiResp.RawBody))
}
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
out := searchUserResponse{Users: users, HasMore: hasMore}
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
@@ -245,6 +238,20 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
return nil
}
func decodeSearchUserAPIData(data map[string]interface{}) (*searchUserAPIData, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, contactInvalidResponseError("marshal search user response data failed").
WithCause(err)
}
var out searchUserAPIData
if err := json.Unmarshal(raw, &out); err != nil {
return nil, contactInvalidResponseError("decode search user response data failed").
WithCause(err)
}
return &out, nil
}
func isHumanReadableFormat(format string) bool {
return format == "pretty" || format == "table"
}
@@ -373,52 +380,74 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
func validateSearchUser(runtime *common.RuntimeContext) error {
if !hasAnySearchInput(runtime) {
return common.FlagErrorf(
return common.ValidationErrorf(
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
).WithParams(
errs.InvalidParam{Name: "--query", Reason: "required; specify at least one search input"},
errs.InvalidParam{Name: "--queries", Reason: "required; specify at least one search input"},
errs.InvalidParam{Name: "--user-ids", Reason: "required; specify at least one search input"},
errs.InvalidParam{Name: "--has-chatted", Reason: "required; specify at least one search input"},
errs.InvalidParam{Name: "--has-enterprise-email", Reason: "required; specify at least one search input"},
errs.InvalidParam{Name: "--exclude-external-users", Reason: "required; specify at least one search input"},
errs.InvalidParam{Name: "--left-organization", Reason: "required; specify at least one search input"},
)
}
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
if queriesRaw != "" {
if strings.TrimSpace(runtime.Str("query")) != "" {
return common.FlagErrorf("--query and --queries are mutually exclusive")
return common.ValidationErrorf("--query and --queries are mutually exclusive").
WithParams(
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
)
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
return common.ValidationErrorf("--user-ids and --queries are mutually exclusive").
WithParams(
errs.InvalidParam{Name: "--user-ids", Reason: "mutually exclusive with --queries"},
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --user-ids"},
)
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
WithParam("--queries")
}
if len(queries) > maxFanoutQueries {
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
WithParam("--queries")
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars).
WithParam("--queries")
}
}
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars).
WithParam("--query")
}
}
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
if err != nil {
return err
}
if len(ids) == 0 {
return common.FlagErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw)
return common.ValidationErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw).
WithParam("--user-ids")
}
if len(ids) > maxSearchUserUserIDs {
return common.FlagErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs)
return common.ValidationErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs).
WithParam("--user-ids")
}
for _, id := range ids {
if _, err := common.ValidateUserID(id); err != nil {
if _, err := common.ValidateUserIDTyped("--user-ids", id); err != nil {
return err
}
}
@@ -429,15 +458,16 @@ func validateSearchUser(runtime *common.RuntimeContext) error {
// silent wrong-result bugs.
for _, bf := range searchUserBoolFilters {
if runtime.Cmd.Flags().Changed(bf.Flag) && !runtime.Bool(bf.Flag) {
return common.FlagErrorf(
return common.ValidationErrorf(
"--%s: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
bf.Flag,
)
).WithParam("--" + bf.Flag)
}
}
if n := runtime.Int("page-size"); n < 1 || n > maxSearchUserPageSize {
return common.FlagErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize)
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize).
WithParam("--page-size")
}
return nil
}
@@ -473,7 +503,7 @@ func buildSearchUserBody(runtime *common.RuntimeContext) (*searchUserAPIRequest,
hasFilter := false
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ package contact
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -47,7 +46,7 @@ type fanoutResult struct {
Users []searchUser
HasMore bool
ErrMsg string // empty = success
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
Err error // original failure, kept for typed all-failed propagation
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
@@ -67,7 +66,7 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
// Pre-check ctx so queued workers see cancellation before issuing a
// request; in-flight workers continue until DoAPI returns.
if err := ctx.Err(); err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
return fanoutErrorResult(index, query, err)
}
body := &searchUserAPIRequest{Query: query}
@@ -82,38 +81,29 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
})
if err != nil {
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
if apiResp.StatusCode != http.StatusOK {
body := strings.TrimSpace(string(apiResp.RawBody))
const maxBody = 200
if len(body) > maxBody {
body = body[:maxBody] + "..."
}
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
if body != "" {
msg = fmt.Sprintf("%s: %s", msg, body)
}
return fanoutResult{Index: index, Query: query,
ErrMsg: msg,
ErrCode: apiResp.StatusCode}
return fanoutErrorResult(index, query, err)
}
var resp searchUserAPIEnvelope
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return fanoutErrorResult(index, query, err)
}
if resp.Code != 0 {
return fanoutResult{Index: index, Query: query,
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
ErrCode: resp.Code}
respData, err := decodeSearchUserAPIData(data)
if err != nil {
return fanoutErrorResult(index, query, err)
}
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
}
func fanoutErrorResult(index int, query string, err error) fanoutResult {
if err == nil {
return fanoutResult{Index: index, Query: query}
}
return fanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
}
type fanoutUser struct {
searchUser
MatchedQuery string `json:"matched_query"`
@@ -146,7 +136,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErrCode int
var firstErr error
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
@@ -158,7 +148,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErrCode = r.ErrCode
firstErr = r.Err
}
continue
}
@@ -169,18 +159,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
if failed == len(queries) && len(queries) > 0 {
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
len(queries), firstErrMsg, firstErrQuery)
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
// means success in the Lark protocol, so don't pretend it's an API error
// when we have nothing structured to report.
if firstErrCode != 0 {
return nil, output.ErrAPI(firstErrCode, msg, "")
}
// No structured API code — the failure was transport, parse, panic, or
// cancellation. Suggest the actionable next step rather than shipping
// an empty hint that would leave the calling agent with nothing to do.
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
return nil, contactFanoutAllFailedError(firstErr, msg)
}
return out, nil
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -16,10 +15,10 @@ import (
"time"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -254,6 +253,16 @@ func TestRowFromItem_CrossTenantEmptyEmailNoPanic(t *testing.T) {
}
}
func TestProjectUsers_NilData(t *testing.T) {
users, hasMore := projectUsers(nil, "", core.BrandFeishu)
if users == nil {
t.Fatalf("users should be an empty slice, not nil")
}
if len(users) != 0 || hasMore {
t.Fatalf("projectUsers(nil): got users=%v hasMore=%v", users, hasMore)
}
}
func TestValidateSearchUser_AllEmpty_Errors(t *testing.T) {
cmd := newSearchUserTestCommand()
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
@@ -479,6 +488,26 @@ func TestBuildBody_UserIDsResolveAndDedup(t *testing.T) {
}
}
func TestBuildBody_UserIDsMeWithoutLoginReturnsTypedError(t *testing.T) {
cmd := newSearchUserTestCommand()
_ = cmd.Flags().Set("user-ids", "me")
cfg := searchUserDefaultConfig()
cfg.UserOpenId = ""
rt := common.TestNewRuntimeContext(cmd, cfg)
body, err := buildSearchUserBody(rt)
if err == nil {
t.Fatalf("expected error, got body %+v", body)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryValidation)
}
}
func TestValidateSearchUser_PageSizeOutOfRange_Errors(t *testing.T) {
for _, n := range []int{0, 31} {
cmd := newSearchUserTestCommand()
@@ -504,6 +533,20 @@ func TestValidateSearchUser_PageSizeBoundaries_OK(t *testing.T) {
}
}
func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
_, err := decodeSearchUserAPIData(map[string]interface{}{"bad": func() {}})
if err == nil {
t.Fatalf("expected marshal failure")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it
// with the given args. Mirrors the pattern used in other shortcut packages.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
@@ -1011,6 +1054,13 @@ func TestRunOneQuery_APINonZeroCode(t *testing.T) {
if got.ErrMsg != "API 99991663: rate limited" {
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
}
p, ok := errs.ProblemOf(got.Err)
if !ok {
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
}
if p.Code != 99991663 {
t.Errorf("problem code: got %d, want 99991663", p.Code)
}
if got.Users != nil || got.HasMore {
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
}
@@ -1032,8 +1082,15 @@ func TestRunOneQuery_HTTPNon200(t *testing.T) {
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
}
if got.ErrCode != 503 {
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
p, ok := errs.ProblemOf(got.Err)
if !ok {
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
}
if p.Code != 503 {
t.Errorf("problem code: got %d, want 503", p.Code)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("problem category: got %q, want %q", p.Category, errs.CategoryNetwork)
}
}
@@ -1080,6 +1137,16 @@ func TestRunOneQuery_TransportError(t *testing.T) {
}
}
func TestFanoutErrorResult_NilErrorIsSuccess(t *testing.T) {
got := fanoutErrorResult(4, "alice", nil)
if got.Index != 4 || got.Query != "alice" {
t.Fatalf("Index/Query mismatch: %+v", got)
}
if got.ErrMsg != "" || got.Err != nil {
t.Fatalf("nil error should produce a success result, got %+v", got)
}
}
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
results := []fanoutResult{
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
@@ -1136,7 +1203,7 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
}
// When all queries fail with no structured Lark API code (transport, parse,
// panic, ctx-canceled), the returned ExitError must carry an actionable
// panic, ctx-canceled), the returned typed error must carry an actionable
// hint so the calling agent has a next step to try instead of giving up.
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
results := []fanoutResult{
@@ -1147,28 +1214,38 @@ func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
if err == nil {
t.Fatalf("expected error when all queries failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if exitErr.Detail == nil {
t.Fatalf("expected Detail, got nil")
if p.Category != errs.CategoryInternal {
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryInternal)
}
if exitErr.Detail.Hint == "" {
if p.Hint == "" {
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
}
if !strings.Contains(exitErr.Detail.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", p.Hint)
}
}
// Codes from the first failure must propagate through output.ErrAPI so the
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// Codes from the first failure must propagate through typed problem fields so
// the CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
// instead of 0, which would mean "success" in the Lark protocol.
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
results := []fanoutResult{
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
{
Index: 0,
Query: "alice",
ErrMsg: "API 99991663: rate limit",
Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663),
},
{
Index: 1,
Query: "bob",
ErrMsg: "HTTP 500",
Err: errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500").WithCode(500),
},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
@@ -1177,6 +1254,16 @@ func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
if !strings.Contains(err.Error(), "rate limit") {
t.Errorf("error should contain first ErrMsg; got %v", err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Code != 99991663 {
t.Errorf("problem code: got %d, want 99991663", p.Code)
}
if p.Subtype != errs.SubtypeRateLimit {
t.Errorf("problem subtype: got %q, want %q", p.Subtype, errs.SubtypeRateLimit)
}
}
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
@@ -1220,6 +1307,37 @@ func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
}
}
func TestPrettyFanoutUserRows(t *testing.T) {
rows := prettyFanoutUserRows([]fanoutUser{
{
searchUser: searchUser{
OpenID: "ou_a",
LocalizedName: "Alice",
Department: strings.Repeat("d", 80),
EnterpriseEmail: "alice@example.com",
HasChatted: true,
ChatRecencyHint: "Contacted yesterday",
},
MatchedQuery: "alice",
},
})
if len(rows) != 1 {
t.Fatalf("rows: got %d, want 1", len(rows))
}
row := rows[0]
for _, key := range []string{"matched_query", "localized_name", "department", "enterprise_email", "has_chatted", "chat_recency_hint", "open_id"} {
if _, ok := row[key]; !ok {
t.Fatalf("row missing key %q: %+v", key, row)
}
}
if row["matched_query"] != "alice" || row["open_id"] != "ou_a" {
t.Fatalf("row identity fields: %+v", row)
}
if len(row["department"].(string)) >= 80 {
t.Fatalf("department should be truncated for table display, got %q", row["department"])
}
}
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
// are no longer accepted. cobra must reject the unknown flag at parse time —
// no stub is registered because the command should never reach the API.

View File

@@ -11,6 +11,8 @@ import (
"regexp"
"runtime"
"strings"
"github.com/larksuite/cli/errs"
)
// readClipboardImageBytes reads the current clipboard image and returns the
@@ -35,13 +37,13 @@ func readClipboardImageBytes() ([]byte, error) {
case "linux":
data, err = readClipboardLinux()
default:
return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS)
}
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("clipboard contains no image data")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
}
return data, nil
}
@@ -91,9 +93,9 @@ func readClipboardDarwin() ([]byte, error) {
}
if stderrText != "" {
return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText)
}
return nil, fmt.Errorf("clipboard contains no image data")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
}
// runOsascript invokes osascript with a single AppleScript expression and
@@ -188,14 +190,14 @@ func decodeOsascriptData(s string) ([]byte, error) {
// decodeHex decodes an uppercase hex string (as produced by osascript) to bytes.
func decodeHex(h string) ([]byte, error) {
if len(h)%2 != 0 {
return nil, fmt.Errorf("odd hex length")
return nil, fmt.Errorf("odd hex length") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
}
b := make([]byte, len(h)/2)
for i := 0; i < len(h); i += 2 {
hi := hexVal(h[i])
lo := hexVal(h[i+1])
if hi < 0 || lo < 0 {
return nil, fmt.Errorf("invalid hex char at %d", i)
return nil, fmt.Errorf("invalid hex char at %d", i) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
}
b[i/2] = byte(hi<<4 | lo)
}
@@ -237,12 +239,12 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("clipboard read failed (%s)", msg)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg)
}
b64 := strings.TrimSpace(string(out))
data, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return nil, fmt.Errorf("clipboard image decode failed: %w", decErr)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr)
}
return data, nil
}
@@ -325,15 +327,15 @@ func readClipboardLinux() ([]byte, error) {
foundTool = true
out, err := exec.Command(t.name, t.args...).Output()
if err != nil {
lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err)
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err)
continue
}
if len(out) == 0 {
lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name)
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name)
continue
}
if t.validatePNG && !hasPNGMagic(out) {
lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name)
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name)
continue
}
return out, nil
@@ -342,8 +344,8 @@ func readClipboardLinux() ([]byte, error) {
if foundTool && lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf(
"clipboard image read failed: no supported tool found. " +
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager " +
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"clipboard image read failed: no supported tool found. "+
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager "+
"(apt, dnf, pacman, apk, brew, etc.).")
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import "github.com/larksuite/cli/errs"
// wrapDocNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving its subtype / code / log_id from the runtime boundary),
// and only wraps a raw, unclassified error as a transport-level network error.
func wrapDocNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

View File

@@ -10,8 +10,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -51,10 +51,10 @@ var DocMediaDownload = common.Shortcut{
overwrite := runtime.Bool("overwrite")
if err := validate.ResourceName(token, "--token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token))
@@ -73,7 +73,7 @@ var DocMediaDownload = common.Shortcut{
ApiPath: apiPath,
})
if err != nil {
return output.ErrNetwork("download failed: %v", err)
return wrapDocNetworkErr(err, "download failed: %v", err)
}
defer resp.Body.Close()
@@ -86,14 +86,14 @@ var DocMediaDownload = common.Shortcut{
// Validate final path after extension append
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
}
// Overwrite check on final path (after extension detection)
if !overwrite {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
}
}
@@ -102,7 +102,7 @@ var DocMediaDownload = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)

View File

@@ -15,8 +15,8 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -67,10 +67,10 @@ var DocMediaInsert = common.Shortcut{
filePath := runtime.Str("file")
fromClipboard := runtime.Bool("from-clipboard")
if filePath == "" && !fromClipboard {
return common.FlagErrorf("one of --file or --from-clipboard is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required")
}
if filePath != "" && fromClipboard {
return common.FlagErrorf("--file and --from-clipboard are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive")
}
docRef, err := parseDocumentRef(runtime.Str("doc"))
@@ -78,7 +78,7 @@ var DocMediaInsert = common.Shortcut{
return err
}
if docRef.Kind == "doc" {
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
}
rawSelection := runtime.Str("selection-with-ellipsis")
trimmedSelection := strings.TrimSpace(rawSelection)
@@ -87,36 +87,36 @@ var DocMediaInsert = common.Shortcut{
// trim-to-empty would make +media-insert fall back to append-mode and
// write at the wrong location.
if rawSelection != "" && trimmedSelection == "" {
return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis must not be blank or whitespace-only").WithParam("--selection-with-ellipsis")
}
if runtime.Bool("before") && trimmedSelection == "" {
return output.ErrValidation("--before requires --selection-with-ellipsis")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--before requires --selection-with-ellipsis").WithParam("--before")
}
if view := runtime.Str("file-view"); view != "" {
if _, ok := fileViewMap[view]; !ok {
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-view value %q, expected one of: card | preview | inline", view).WithParam("--file-view")
}
if runtime.Str("type") != "file" {
return output.ErrValidation("--file-view only applies when --type=file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-view only applies when --type=file").WithParam("--file-view")
}
}
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
return output.ErrValidation("--width/--height only apply when --type=image")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image")
}
if widthChanged && runtime.Int("width") <= 0 {
return output.ErrValidation("--width must be a positive integer")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width")
}
if heightChanged && runtime.Int("height") <= 0 {
return output.ErrValidation("--height must be a positive integer")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be a positive integer").WithParam("--height")
}
const maxDimension = 10000
if widthChanged && runtime.Int("width") > maxDimension {
return output.ErrValidation("--width must not exceed %d pixels", maxDimension)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must not exceed %d pixels", maxDimension).WithParam("--width")
}
if heightChanged && runtime.Int("height") > maxDimension {
return output.ErrValidation("--height must not exceed %d pixels", maxDimension)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must not exceed %d pixels", maxDimension).WithParam("--height")
}
return nil
},
@@ -269,10 +269,10 @@ var DocMediaInsert = common.Shortcut{
} else {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return common.WrapInputStatErrorTyped(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
fileSize = stat.Size()
fileName = filepath.Base(filePath)
@@ -284,7 +284,7 @@ var DocMediaInsert = common.Shortcut{
}
// Step 1: Get document root block to find where to insert
rootData, err := runtime.CallAPI("GET",
rootData, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)),
nil, nil)
if err != nil {
@@ -318,7 +318,7 @@ var DocMediaInsert = common.Shortcut{
// Step 2: Create an empty block at the target position
fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex)
createData, err := runtime.CallAPI("POST",
createData, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
if err != nil {
@@ -328,7 +328,7 @@ var DocMediaInsert = common.Shortcut{
blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType)
if blockId == "" {
return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create block: no block_id returned")
}
fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId)
@@ -340,7 +340,7 @@ var DocMediaInsert = common.Shortcut{
// later steps should try to remove it instead of leaving an empty artifact.
rollback := func() error {
fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId)
_, err := runtime.CallAPI("DELETE",
_, err := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
nil, buildDeleteBlockData(insertIndex))
return err
@@ -379,14 +379,14 @@ var DocMediaInsert = common.Shortcut{
} else {
f, openErr := runtime.FileIO().Open(filePath)
if openErr != nil {
return withRollbackWarning(output.ErrValidation(
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
}
nativeW, nativeH, dimErr = detectImageDimensions(f)
f.Close()
}
if dimErr != nil {
return withRollbackWarning(output.ErrValidation(
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
}
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
@@ -417,7 +417,7 @@ var DocMediaInsert = common.Shortcut{
// Step 4: Bind file token to block via batch_update
fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID)
if _, err := runtime.CallAPI("PATCH",
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil {
return withRollbackWarning(err)
@@ -512,10 +512,10 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
case "docx":
return docRef.Token, nil
case "doc":
return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docRef.Token},
@@ -529,16 +529,16 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "docx" {
return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
return objToken, nil
default:
return "", output.ErrValidation("docs +media-insert only supports docx documents")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
}
}
@@ -622,7 +622,7 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin
func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (parentBlockID string, insertIndex int, children []interface{}, err error) {
block, _ := rootData["block"].(map[string]interface{})
if len(block) == 0 {
return "", 0, nil, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block")
return "", 0, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to query document root block")
}
parentBlockID = fallbackBlockID
@@ -653,12 +653,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
matches := common.GetSlice(result, "matches")
if len(matches) == 0 {
return 0, output.ErrWithHint(
output.ExitValidation,
"no_match",
fmt.Sprintf("locate-doc did not find any block matching selection (%s)", redactSelection(selection)),
"check spelling or use 'start...end' syntax to narrow the selection",
)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"locate-doc did not find any block matching selection (%s)", redactSelection(selection)).
WithParam("--selection-with-ellipsis").
WithHint("check spelling or use 'start...end' syntax to narrow the selection")
}
if len(matches) > 1 {
// Silently picking the first match surprises users whose selection appears
@@ -682,7 +680,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
}
}
if anchorBlockID == "" {
return 0, output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
return 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
}
parentBlockID := common.GetString(matchMap, "parent_block_id")
@@ -740,7 +738,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
nextParent = "" // clear hint after first use
if parent == "" || parent == cur {
// Need to fetch this block to find its parent.
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s",
validate.EncodePathSegment(documentID), validate.EncodePathSegment(cur)),
nil, nil)
@@ -757,12 +755,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
walkDepth++
}
return 0, output.ErrWithHint(
output.ExitValidation,
"block_not_reachable",
fmt.Sprintf("block matching selection (%s) is not reachable from document root", redactSelection(selection)),
"try a top-level heading or paragraph as the selection",
)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"block matching selection (%s) is not reachable from document root", redactSelection(selection)).
WithParam("--selection-with-ellipsis").
WithHint("try a top-level heading or paragraph as the selection")
}
func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) {

View File

@@ -10,8 +10,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -45,11 +45,11 @@ var DocMediaPreview = common.Shortcut{
overwrite := runtime.Bool("overwrite")
if err := validate.ResourceName(token, "--token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
// Early path validation before API call (final validation after auto-extension below)
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token))
@@ -65,7 +65,7 @@ var DocMediaPreview = common.Shortcut{
},
})
if err != nil {
return output.ErrNetwork("preview failed: %v", err)
return wrapDocNetworkErr(err, "preview failed: %v", err)
}
defer resp.Body.Close()
@@ -74,14 +74,14 @@ var DocMediaPreview = common.Shortcut{
// Validate final path after extension append
if finalPath != outputPath {
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
}
// Overwrite check on final path (after extension detection)
if !overwrite {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
}
}
@@ -90,7 +90,7 @@ var DocMediaPreview = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)

View File

@@ -9,8 +9,8 @@ import (
"io"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -84,10 +84,10 @@ var DocMediaUpload = common.Shortcut{
// Validate file
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return common.WrapInputStatErrorTyped(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
fileName := filepath.Base(filePath)

View File

@@ -7,6 +7,7 @@ import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,10 +26,10 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive")
}
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,7 +38,7 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return common.FlagErrorf("invalid --doc: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
if err := validateFetchDetail(runtime); err != nil {
return err
@@ -153,7 +154,7 @@ func validateFetchDetail(runtime *common.RuntimeContext) error {
return nil
}
if detail == "with-ids" || detail == "full" {
return common.FlagErrorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
}
return nil
}
@@ -166,13 +167,13 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
}
if v := runtime.Int("context-before"); v < 0 {
return common.FlagErrorf("--context-before must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-before must be >= 0, got %d", v).WithParam("--context-before")
}
if v := runtime.Int("context-after"); v < 0 {
return common.FlagErrorf("--context-after must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-after must be >= 0, got %d", v).WithParam("--context-after")
}
if v := runtime.Int("max-depth"); v < -1 {
return common.FlagErrorf("--max-depth must be >= -1, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-depth must be >= -1, got %d", v).WithParam("--max-depth")
}
switch mode {
@@ -181,20 +182,20 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return common.FlagErrorf("range mode requires --start-block-id or --end-block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id")
}
return nil
case "keyword":
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return common.FlagErrorf("keyword mode requires --keyword")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keyword mode requires --keyword").WithParam("--keyword")
}
return nil
case "section":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
return common.FlagErrorf("section mode requires --start-block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "section mode requires --start-block-id").WithParam("--start-block-id")
}
return nil
default:
return common.FlagErrorf("invalid --scope %q", mode)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
}
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -58,7 +59,7 @@ var DocsSearch = common.Shortcut{
return err
}
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
if err != nil {
return err
}
@@ -159,7 +160,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
var filter map[string]interface{}
if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
return nil, output.ErrValidation("--filter is not valid JSON")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter")
}
if err := convertTimeRangeInFilter(filter, "open_time"); err != nil {
return nil, err
@@ -172,7 +173,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
hasSpaceIDs := hasNonEmptyFilterArray(filter, "space_ids")
if hasFolderTokens && hasSpaceIDs {
return nil, output.ErrValidation("--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined").WithParam("--filter")
}
docFilter := cloneFilterMap(filter)
@@ -225,14 +226,14 @@ func convertTimeRangeInFilter(filter map[string]interface{}, key string) error {
if start, ok := rangeMap["start"].(string); ok && start != "" {
startTime, err := toUnixSeconds(start)
if err != nil {
return output.ErrValidation("invalid %s.start %q: %s", key, start, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter")
}
result["start"] = startTime
}
if end, ok := rangeMap["end"].(string); ok && end != "" {
endTime, err := toUnixSeconds(end)
if err != nil {
return output.ErrValidation("invalid %s.end %q: %s", key, end, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter")
}
result["end"] = endTime
}
@@ -256,7 +257,7 @@ func toUnixSeconds(input string) (int64, error) {
if n, err := strconv.ParseInt(input, 10, 64); err == nil {
return n, nil
}
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds")
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
func unixTimestampToISO8601(v interface{}) string {

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,14 +44,14 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return common.FlagErrorf("invalid --doc: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
cmd := runtime.Str("command")
if cmd == "" {
return common.FlagErrorf("--command is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command is required").WithParam("--command")
}
if !validCommandsV2[cmd] {
return common.FlagErrorf("invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
}
content := runtime.Str("content")
pattern := runtime.Str("pattern")
@@ -60,50 +61,50 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
switch cmd {
case "str_replace":
if pattern == "" {
return common.FlagErrorf("--command str_replace requires --pattern")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return common.FlagErrorf("--command block_delete requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
case "block_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_insert_after requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
}
if content == "" {
return common.FlagErrorf("--command block_insert_after requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --content").WithParam("--content")
}
case "block_copy_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --block-id").WithParam("--block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --src-block-ids").WithParam("--src-block-ids")
}
case "block_move_after":
if blockID == "" {
return common.FlagErrorf("--command block_move_after requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --block-id").WithParam("--block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_move_after requires --src-block-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --src-block-ids").WithParam("--src-block-ids")
}
if content != "" {
return common.FlagErrorf("--command block_move_after does not accept --content; use --src-block-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after does not accept --content; use --src-block-ids").WithParam("--content")
}
case "block_replace":
if blockID == "" {
return common.FlagErrorf("--command block_replace requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --block-id").WithParam("--block-id")
}
if content == "" {
return common.FlagErrorf("--command block_replace requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --content").WithParam("--content")
}
case "overwrite":
if content == "" {
return common.FlagErrorf("--command overwrite requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command overwrite requires --content").WithParam("--content")
}
case "append":
if content == "" {
return common.FlagErrorf("--command append requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
return nil

View File

@@ -8,7 +8,7 @@ import (
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -24,7 +24,7 @@ type documentRef struct {
func parseDocumentRef(input string) (documentRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return documentRef{}, output.ErrValidation("--doc cannot be empty")
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
@@ -37,10 +37,10 @@ func parseDocumentRef(input string) (documentRef, error) {
return documentRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw)
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
}
if strings.ContainsAny(raw, "/?#") {
return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx token or a wiki URL", raw).WithParam("--doc")
}
return documentRef{Kind: "docx", Token: raw}, nil
@@ -64,10 +64,10 @@ func extractDocumentToken(raw, marker string) (string, bool) {
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
// Uses the log-id-aware variant so the x-tt-logid header is surfaced in both the
// success payload and error details — doc v2 callers rely on it for support escalations.
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
// surfaces for support escalations even when the body omits it.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body)
return runtime.CallAPITyped(method, apiPath, nil, body)
}
func docsSceneFromContext(ctx context.Context) string {
@@ -87,7 +87,7 @@ func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}
func buildDriveRouteExtra(docID string) (string, error) {
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
if err != nil {
return "", output.Errorf(output.ExitInternal, "internal_error", "failed to marshal upload extra data: %v", err)
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to marshal upload extra data: %v", err).WithCause(err)
}
return string(extra), nil
}

View File

@@ -6,6 +6,7 @@ package doc
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -65,7 +66,7 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF
switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
case "", "v1", "v2":
default:
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API")
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API", "--api-version")
}
var used []string
@@ -87,11 +88,12 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF
if len(replacements) > 0 {
detail += "; " + strings.Join(replacements, "; ")
}
return docsV2OnlyError(shortcut, detail)
return docsV2OnlyError(shortcut, detail, used[0])
}
func docsV2OnlyError(shortcut, detail string) error {
return common.FlagErrorf(
func docsV2OnlyError(shortcut, detail, param string) error {
err := errs.NewValidationError(
errs.SubtypeInvalidArgument,
"docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags",
shortcut,
detail,
@@ -100,4 +102,8 @@ func docsV2OnlyError(shortcut, detail string) error {
docsMDSkillReadCommand,
docsHelpCommandForShortcut(shortcut),
)
if param != "" {
err = err.WithParam(param)
}
return err
}

View File

@@ -6,7 +6,6 @@ package markdown
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -19,7 +18,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -85,16 +83,18 @@ func (spec markdownUploadSpec) Target() markdownUploadTarget {
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
switch {
case spec.ContentSet && spec.FileSet:
return common.FlagErrorf("--content and --file are mutually exclusive")
return markdownValidationError("--content and --file are mutually exclusive").
WithParams(markdownInvalidParam("--content", "mutually exclusive"), markdownInvalidParam("--file", "mutually exclusive"))
case !spec.ContentSet && !spec.FileSet:
return common.FlagErrorf("specify exactly one of --content or --file")
return markdownValidationError("specify exactly one of --content or --file").
WithParams(markdownInvalidParam("--content", "required; specify exactly one"), markdownInvalidParam("--file", "required; specify exactly one"))
}
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
return markdownValidationParamError("--folder-token", "--folder-token cannot be empty; omit it to upload into Drive root folder")
}
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
return markdownValidationParamError("--wiki-token", "--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
}
targets := 0
if spec.FolderToken != "" {
@@ -104,22 +104,23 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
targets++
}
if targets > 1 {
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
return markdownValidationError("--folder-token and --wiki-token are mutually exclusive").
WithParams(markdownInvalidParam("--folder-token", "mutually exclusive"), markdownInvalidParam("--wiki-token", "mutually exclusive"))
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--folder-token", "%s", err).WithCause(err)
}
}
if spec.WikiToken != "" {
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--wiki-token", "%s", err).WithCause(err)
}
}
if requireName && spec.ContentSet {
if strings.TrimSpace(spec.FileName) == "" {
return common.FlagErrorf("--name is required when using --content")
return markdownValidationParamError("--name", "--name is required when using --content")
}
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
return err
@@ -128,10 +129,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
if spec.FileSet {
if strings.TrimSpace(spec.FilePath) == "" {
return common.FlagErrorf("--file cannot be empty")
return markdownValidationParamError("--file", "--file cannot be empty")
}
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
}
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
return err
@@ -154,10 +155,10 @@ func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string
func validateMarkdownFileName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return common.FlagErrorf("%s cannot be empty", flagName)
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
return common.FlagErrorf("%s must end with .md", flagName)
return markdownValidationParamError(flagName, "%s must end with .md", flagName)
}
return nil
}
@@ -201,22 +202,9 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
return resp, nil
}
func wrapMarkdownDownloadError(err error) error {
// Preserve any already-classified error: legacy *output.ExitError or any
// typed errs.* error. Only un-classified errors get wrapped as network.
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return output.ErrNetwork("download failed: %s", err)
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
return markdownValidationError("%s", markdownEmptyContentError)
}
return nil
}
@@ -227,12 +215,12 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
size = int64(len(spec.Content))
} else {
if strings.TrimSpace(spec.FilePath) == "" {
return 0, common.FlagErrorf("--file cannot be empty")
return 0, markdownValidationParamError("--file", "--file cannot be empty")
}
info, err := runtime.FileIO().Stat(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
return 0, common.WrapInputStatErrorTyped(err)
}
size = info.Size()
}
@@ -563,7 +551,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
if readErr != nil {
return output.ErrValidation("cannot read file: %s", readErr)
return markdownValidationError("cannot read file: %s", readErr).WithCause(readErr)
}
fd := larkcore.NewFormdata()

View File

@@ -5,7 +5,6 @@ package markdown
import (
"context"
"errors"
"fmt"
"io"
"regexp"
@@ -14,6 +13,7 @@ import (
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -65,7 +65,7 @@ type markdownDiffHunkRange struct {
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
if spec.FromVersion != "" {
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
@@ -79,29 +79,29 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
}
if spec.FilePath != "" {
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
}
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
return err
}
}
if spec.ContextLines < 0 {
return output.ErrValidation("--context-lines must be >= 0")
return markdownValidationParamError("--context-lines", "--context-lines must be >= 0")
}
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
return output.ErrValidation("markdown +diff only supports --format json or pretty")
return markdownValidationParamError("--format", "markdown +diff only supports --format json or pretty")
}
if spec.FilePath == "" {
if spec.FromVersion == "" && spec.ToVersion == "" {
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
return markdownValidationError("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
}
if spec.FromVersion == "" && spec.ToVersion != "" {
return common.FlagErrorf("--to-version requires --from-version")
return markdownValidationParamError("--to-version", "--to-version requires --from-version")
}
return nil
}
if spec.ToVersion != "" {
return common.FlagErrorf("--to-version is not supported together with --file")
return markdownValidationParamError("--to-version", "--to-version is not supported together with --file")
}
return nil
}
@@ -109,10 +109,10 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
if !markdownDiffVersionRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric version string", flagName)
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
}
return nil
}
@@ -178,17 +178,16 @@ func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", common.WrapInputStatErrorTyped(err)
}
defer f.Close()
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", output.ErrValidation("cannot read file: %s", err)
return "", markdownValidationError("cannot read file: %s", err).WithCause(err)
}
return string(payload), nil
}
@@ -199,7 +198,7 @@ func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
return nil, err
}
if len(payload) > markdownDiffMaxContentBytes {
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
return nil, markdownValidationError("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
}
return payload, nil
}

View File

@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -214,18 +215,18 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
}
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(99991663)
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
}
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
problem, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("wrapMarkdownDownloadError() = %T, want typed problem", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
if problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
t.Fatalf("wrapped error = %q", got.Error())

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"strings"
"github.com/larksuite/cli/errs"
)
func markdownValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func markdownValidationParamError(param, format string, args ...any) *errs.ValidationError {
return markdownValidationError(format, args...).WithParam(param)
}
func markdownInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: name, Reason: reason}
}
func markdownNetworkError(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
func wrapMarkdownDownloadError(err error) error {
if p, ok := errs.ProblemOf(err); ok {
if p.Category == errs.CategoryValidation {
return err
}
return markdownPrefixProblem(err, "download failed")
}
return markdownNetworkError(err, "download failed: %s", err)
}
func markdownPrefixProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(action) != "" {
p.Message = action + ": " + p.Message
}
return err
}
return errs.WrapInternal(err)
}

View File

@@ -14,7 +14,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,14 +34,14 @@ var MarkdownFetch = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath == "" {
return nil
}
if _, err := validate.SafeOutputPath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return markdownValidationParamError("--output", "unsafe output path: %s", err).WithCause(err)
}
return nil
},
@@ -67,7 +66,7 @@ var MarkdownFetch = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapMarkdownDownloadError(err)
}
defer resp.Body.Close()
@@ -75,7 +74,7 @@ var MarkdownFetch = common.Shortcut{
if outputPath == "" {
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapMarkdownDownloadError(err)
}
out := map[string]interface{}{
"file_token": fileToken,
@@ -93,7 +92,7 @@ var MarkdownFetch = common.Shortcut{
outputPath = filepath.Join(outputPath, fileName)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return markdownValidationParamError("--output", "output file already exists: %s (use --overwrite to replace)", outputPath)
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
@@ -101,7 +100,7 @@ var MarkdownFetch = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)

View File

@@ -8,7 +8,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +29,7 @@ var MarkdownOverwrite = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileToken: fileToken,

View File

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -49,7 +48,7 @@ var MarkdownPatch = common.Shortcut{
}
if spec.Regex {
if _, err := regexp.Compile(spec.Pattern); err != nil {
return output.ErrValidation("invalid --pattern regex: %s", err)
return markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
}
}
return nil
@@ -122,7 +121,7 @@ var MarkdownPatch = common.Shortcut{
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapMarkdownDownloadError(err)
}
original := string(payload)
patched, matchCount, err := applyMarkdownPatch(original, spec)
@@ -192,16 +191,16 @@ func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
if !runtime.Changed("pattern") {
return common.FlagErrorf("--pattern is required")
return markdownValidationParamError("--pattern", "--pattern is required")
}
if spec.Pattern == "" {
return output.ErrValidation("--pattern cannot be empty")
return markdownValidationParamError("--pattern", "--pattern cannot be empty")
}
if !spec.ContentSet {
return common.FlagErrorf("--content is required")
return markdownValidationParamError("--content", "--content is required")
}
return nil
}
@@ -212,7 +211,7 @@ func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, e
}
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
return "", 0, markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
}
matches := re.FindAllStringIndex(original, -1)
return re.ReplaceAllString(original, spec.Content), len(matches), nil

View File

@@ -97,13 +97,11 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
}
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
// Brand only — never decrypt the app secret at registration time (avoids a
// keychain read on every invocation). ConfigBrand may be nil in tests that
// pass a zero-value factory.
// Factory.Config may be nil in tests that pass a zero-value factory.
var brand core.LarkBrand
if f != nil && f.ConfigBrand != nil {
if b, ok := f.ConfigBrand(); ok {
brand = b
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
brand = cfg.Brand
}
}

View File

@@ -20,10 +20,6 @@ func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: brand}, nil
},
// Registration reads the brand via ConfigBrand (no secret decryption).
ConfigBrand: func() (core.LarkBrand, bool) {
return brand, true
},
}
}

View File

@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,7 +27,7 @@ var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
@@ -38,7 +38,7 @@ func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (s
return id, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
@@ -104,7 +104,7 @@ func validateSheetRangeInput(sheetID, input string) error {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
return common.ValidationErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
@@ -127,7 +127,7 @@ func validateSingleCellRange(input string) error {
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
return common.ValidationErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
@@ -197,11 +197,11 @@ func matrixDimensions(values interface{}) (rows, cols int) {
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
return "", fmt.Errorf("invalid cell reference: %s", cell) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
return "", fmt.Errorf("invalid column: %s", matches[1]) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -15,10 +16,10 @@ import (
func parseValues2DJSON(raw string) ([][]interface{}, error) {
var rows [][]interface{}
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
}
if rows == nil {
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
}
return rows, nil
}
@@ -46,7 +47,7 @@ var SheetRead = common.Shortcut{
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
}
}
return nil
@@ -90,7 +91,7 @@ var SheetRead = common.Shortcut{
params["valueRenderOption"] = renderOption
}
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
if err != nil {
return err
}
@@ -167,7 +168,7 @@ var SheetWrite = common.Shortcut{
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
}
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": writeRange,
"values": values,
@@ -247,7 +248,7 @@ var SheetAppend = common.Shortcut{
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
}
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": appendRange,
"values": values,
@@ -288,7 +289,7 @@ var SheetFind = common.Shortcut{
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
}
}
return nil
@@ -336,7 +337,7 @@ var SheetFind = common.Shortcut{
"find": findText,
}
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
if err != nil {
return err
}
@@ -373,7 +374,7 @@ var SheetReplace = common.Shortcut{
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
}
}
return nil
@@ -415,7 +416,7 @@ var SheetReplace = common.Shortcut{
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
validate.EncodePathSegment(token),
validate.EncodePathSegment(sheetID),

View File

@@ -11,8 +11,8 @@ import (
"os"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -38,7 +38,7 @@ var SheetWriteImage = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -91,7 +91,7 @@ var SheetWriteImage = common.Shortcut{
imageBytes, err := io.ReadAll(imageFile)
if err != nil {
return output.ErrValidation("cannot read image file: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
}
imageName := runtime.Str("name")
@@ -101,7 +101,7 @@ var SheetWriteImage = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"range": pointRange,
"image": imageBytes,
"name": imageName,
@@ -116,35 +116,35 @@ var SheetWriteImage = common.Shortcut{
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
if fio == nil {
return nil, output.ErrValidation("no file I/O provider registered")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no file I/O provider registered")
}
stat, err := fio.Stat(imagePath)
if err != nil {
return nil, wrapSheetWriteImageStatError(err, imagePath)
}
if stat.IsDir() || !stat.Mode().IsRegular() {
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image must be a regular file: %s", imagePath).WithParam("--image")
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024).WithParam("--image")
}
return stat, nil
}
func wrapSheetWriteImageStatError(err error, imagePath string) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
}
if os.IsNotExist(err) {
return output.ErrValidation("image file not found: %s", imagePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "image file not found: %s", imagePath).WithParam("--image").WithCause(err)
}
return output.ErrValidation("cannot stat image file: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot stat image file: %s", err).WithParam("--image").WithCause(err)
}
func wrapSheetWriteImageOpenError(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
}
return output.ErrValidation("cannot read image file: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -15,40 +16,40 @@ import (
func validateBatchStyleData(raw string) error {
var data interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
}
arr, ok := data.([]interface{})
if !ok || len(arr) == 0 {
return common.FlagErrorf("--data must be a non-empty JSON array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a non-empty JSON array").WithParam("--data")
}
for i, item := range arr {
entry, ok := item.(map[string]interface{})
if !ok {
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d] must be an object with ranges and style", i).WithParam("--data")
}
rangesRaw, ok := entry["ranges"]
if !ok {
return common.FlagErrorf("--data[%d].ranges is required", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges is required", i).WithParam("--data")
}
ranges, ok := rangesRaw.([]interface{})
if !ok || len(ranges) == 0 {
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges must be a non-empty array of strings", i).WithParam("--data")
}
for j, r := range ranges {
s, ok := r.(string)
if !ok || s == "" {
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] must be a non-empty string", i, j).WithParam("--data")
}
if _, _, ok := splitSheetRange(s); !ok {
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s).WithParam("--data")
}
}
styleRaw, ok := entry["style"]
if !ok {
return common.FlagErrorf("--data[%d].style is required", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style is required", i).WithParam("--data")
}
if _, ok := styleRaw.(map[string]interface{}); !ok {
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style must be a JSON object", i).WithParam("--data")
}
}
return nil
@@ -74,14 +75,14 @@ var SheetSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
}
if _, ok := style.(map[string]interface{}); !ok {
return common.FlagErrorf("--style must be a JSON object, got %T", style)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be a JSON object, got %T", style).WithParam("--style")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -115,10 +116,10 @@ var SheetSetStyle = common.Shortcut{
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
}
data, err := runtime.CallAPI("PUT",
data, err := runtime.CallAPITyped("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -154,7 +155,7 @@ var SheetBatchSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return validateBatchStyleData(runtime.Str("data"))
},
@@ -181,11 +182,11 @@ var SheetBatchSetStyle = common.Shortcut{
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
}
normalizeBatchStyleRanges(data)
result, err := runtime.CallAPI("PUT",
result, err := runtime.CallAPITyped("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -242,7 +243,7 @@ var SheetMergeCells = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -271,7 +272,7 @@ var SheetMergeCells = common.Shortcut{
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -306,7 +307,7 @@ var SheetUnmergeCells = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -334,7 +335,7 @@ var SheetUnmergeCells = common.Shortcut{
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,7 +28,7 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -35,10 +36,10 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array of strings: %v", flagName, err).WithParam("--" + flagName)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array, got null", flagName).WithParam("--" + flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
@@ -53,12 +54,12 @@ func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges must not be empty").WithParam("--ranges")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s).WithParam("--ranges")
}
}
return ranges, nil
@@ -70,7 +71,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--condition-values must not be empty").WithParam("--condition-values")
}
dv := map[string]interface{}{
@@ -90,7 +91,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)).WithParam("--colors")
}
opts["colors"] = colors
}
@@ -123,7 +124,7 @@ var SheetSetDropdown = common.Shortcut{
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
}
_, err := buildDropdownBody(runtime)
return err
@@ -147,7 +148,7 @@ var SheetSetDropdown = common.Shortcut{
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
data, err := runtime.CallAPITyped("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
@@ -214,7 +215,7 @@ var SheetUpdateDropdown = common.Shortcut{
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
data, err := runtime.CallAPITyped("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
@@ -247,7 +248,7 @@ var SheetGetDropdown = common.Shortcut{
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
}
return nil
},
@@ -259,7 +260,7 @@ var SheetGetDropdown = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
data, err := runtime.CallAPITyped("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
@@ -319,7 +320,7 @@ var SheetDeleteDropdown = common.Shortcut{
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
data, err := runtime.CallAPITyped("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -59,7 +59,7 @@ var SheetCreateFilterView = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range must not be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must not be empty").WithParam("--range")
}
return nil
},
@@ -85,7 +85,7 @@ var SheetCreateFilterView = common.Shortcut{
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
data, err := runtime.CallAPITyped("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
@@ -115,7 +115,7 @@ var SheetUpdateFilterView = common.Shortcut{
}
if !hasNonEmptyStringFlag(runtime, "range") &&
!hasNonEmptyStringFlag(runtime, "filter-view-name") {
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range or --filter-view-name")
}
return nil
},
@@ -141,7 +141,7 @@ var SheetUpdateFilterView = common.Shortcut{
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
data, err := runtime.CallAPITyped("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
@@ -174,7 +174,7 @@ var SheetListFilterViews = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
data, err := runtime.CallAPITyped("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
@@ -208,7 +208,7 @@ var SheetGetFilterView = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
data, err := runtime.CallAPITyped("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
@@ -242,7 +242,7 @@ var SheetDeleteFilterView = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
data, err := runtime.CallAPITyped("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
@@ -284,7 +284,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
data, err := runtime.CallAPITyped("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
@@ -317,7 +317,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
if !hasNonEmptyStringFlag(runtime, "filter-type") &&
!hasNonEmptyStringFlag(runtime, "compare-type") &&
!hasNonEmptyStringFlag(runtime, "expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
@@ -335,7 +335,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
data, err := runtime.CallAPI("PUT",
data, err := runtime.CallAPITyped("PUT",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, body)
if err != nil {
@@ -371,7 +371,7 @@ var SheetListFilterViewConditions = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
nil, nil)
if err != nil {
@@ -409,7 +409,7 @@ var SheetGetFilterViewCondition = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
@@ -447,7 +447,7 @@ var SheetDeleteFilterViewCondition = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE",
data, err := runtime.CallAPITyped("DELETE",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
@@ -464,7 +464,7 @@ func validateExpectedFlag(s string) error {
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expected must be a JSON array (e.g. [\"6\"]), got: %s", s).WithParam("--expected")
}
return nil
}

View File

@@ -8,8 +8,8 @@ import (
"fmt"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -115,10 +115,10 @@ var SheetMediaUpload = common.Shortcut{
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", nil, common.WrapInputStatError(err, "file not found")
return "", nil, common.WrapInputStatErrorTyped(err, "file not found")
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
return filePath, stat, nil
}
@@ -131,7 +131,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -181,7 +181,7 @@ func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -194,7 +194,7 @@ func validateFloatImageRange(sheetID, rangeVal string) error {
return err
}
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range prefix %q does not match --sheet-id %q", prefix, sheetID).WithParam("--range")
}
return nil
}
@@ -206,7 +206,7 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
runtime.Cmd.Flags().Changed("offset-x") ||
runtime.Cmd.Flags().Changed("offset-y")
if !hasField {
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
}
return nil
}
@@ -214,22 +214,22 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
func validateFloatImageDims(runtime *common.RuntimeContext) error {
if runtime.Cmd.Flags().Changed("width") {
if v := runtime.Int("width"); v < 20 {
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be >= 20 pixels, got %d", v).WithParam("--width")
}
}
if runtime.Cmd.Flags().Changed("height") {
if v := runtime.Int("height"); v < 20 {
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be >= 20 pixels, got %d", v).WithParam("--height")
}
}
if runtime.Cmd.Flags().Changed("offset-x") {
if v := runtime.Int("offset-x"); v < 0 {
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-x must be >= 0, got %d", v).WithParam("--offset-x")
}
}
if runtime.Cmd.Flags().Changed("offset-y") {
if v := runtime.Int("offset-y"); v < 0 {
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-y must be >= 0, got %d", v).WithParam("--offset-y")
}
}
return nil
@@ -304,7 +304,7 @@ var SheetCreateFloatImage = common.Shortcut{
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
data, err := runtime.CallAPITyped("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
@@ -353,7 +353,7 @@ var SheetUpdateFloatImage = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
data, err := runtime.CallAPITyped("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
if err != nil {
return err
}
@@ -387,7 +387,7 @@ var SheetGetFloatImage = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
data, err := runtime.CallAPITyped("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
@@ -420,7 +420,7 @@ var SheetListFloatImages = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
data, err := runtime.CallAPITyped("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
@@ -454,7 +454,7 @@ var SheetDeleteFloatImage = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
data, err := runtime.CallAPITyped("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,7 +32,7 @@ var SheetAddDimension = common.Shortcut{
}
length := runtime.Int("length")
if length < 1 || length > 5000 {
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--length must be between 1 and 5000, got %d", length).WithParam("--length")
}
return nil
},
@@ -51,7 +52,7 @@ var SheetAddDimension = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -91,10 +92,10 @@ var SheetInsertDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
}
if runtime.Int("end-index") <= runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be greater than --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be greater than --start-index").WithParam("--end-index")
}
return nil
},
@@ -131,7 +132,7 @@ var SheetInsertDimension = common.Shortcut{
body["inheritStyle"] = s
}
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
nil, body,
)
@@ -165,16 +166,16 @@ var SheetUpdateDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
}
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --visible or --fixed-size")
}
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
return common.FlagErrorf("--fixed-size must be >= 1")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fixed-size must be >= 1").WithParam("--fixed-size")
}
return nil
},
@@ -211,7 +212,7 @@ var SheetUpdateDimension = common.Shortcut{
props["fixedSize"] = runtime.Int("fixed-size")
}
data, err := runtime.CallAPI("PUT",
data, err := runtime.CallAPITyped("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -253,13 +254,13 @@ var SheetMoveDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
}
if runtime.Int("destination-index") < 0 {
return common.FlagErrorf("--destination-index must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--destination-index must be >= 0").WithParam("--destination-index")
}
return nil
},
@@ -281,7 +282,7 @@ var SheetMoveDimension = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token),
validate.EncodePathSegment(runtime.Str("sheet-id")),
@@ -324,10 +325,10 @@ var SheetDeleteDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
}
return nil
},
@@ -348,7 +349,7 @@ var SheetDeleteDimension = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("DELETE",
data, err := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{

View File

@@ -76,6 +76,23 @@ func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) {
}
}
func TestSheetExportDryRunRejectsUnsafeOutputPath(t *testing.T) {
t.Parallel()
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetExport, []string{
"+export",
"--spreadsheet-token", "shtTOKEN",
"--file-extension", "xlsx",
"--output-path", "../escape.xlsx",
"--dry-run",
"--as", "user",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
t.Fatalf("expected unsafe output-path validation error, got: %v", err)
}
}
func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) {
t.Parallel()

View File

@@ -6,14 +6,13 @@ package backward
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/tidwall/gjson"
)
@@ -402,38 +401,26 @@ func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.
t.Fatal("expected move failure, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if exitErr.Detail.Code != 1310211 {
t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code)
if p.Code != 1310211 {
t.Fatalf("error code = %d, want 1310211", p.Code)
}
if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) {
t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message)
if !strings.Contains(p.Message, `sheet copied successfully as "sheet_copy"`) {
t.Fatalf("message missing copied sheet id: %q", p.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") {
t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "do not retry +copy-sheet") {
t.Fatalf("hint missing retry guard: %q", p.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
// The recovery command in the hint is the AI-actionable signal: retry only
// the move (not the whole +copy-sheet, which would duplicate the sheet).
if !strings.Contains(p.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") {
t.Fatalf("hint missing recovery command: %q", p.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["partial_success"] != true {
t.Fatalf("partial_success = %#v, want true", detail["partial_success"])
}
if detail["sheet_id"] != "sheet_copy" {
t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy")
}
if detail["requested_index"] != 2 {
t.Fatalf("requested_index = %#v, want 2", detail["requested_index"])
}
if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" {
t.Fatalf("retry_command = %#v", detail["retry_command"])
}
if detail["log_id"] != "log-move-failed" {
t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed")
if p.LogID != "log-move-failed" {
t.Fatalf("log_id = %q, want %q", p.LogID, "log-move-failed")
}
}

View File

@@ -5,11 +5,10 @@ package backward
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -21,63 +20,63 @@ func sheetBatchUpdatePath(token string) string {
}
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--spreadsheet-token").WithCause(err)
}
return token, nil
}
url := strings.TrimSpace(runtime.Str("url"))
if url == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/<token>").WithParam("--url")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--url").WithCause(err)
}
return token, nil
}
func validateSheetID(flagName, sheetID string) error {
if strings.TrimSpace(sheetID) == "" {
return common.FlagErrorf("specify --%s", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --%s", flagName).WithParam("--" + flagName)
}
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
return common.FlagErrorf("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
}
return nil
}
func validateSheetTitle(flagName, title string) error {
if title == "" {
return common.FlagErrorf("--%s must not be empty", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not be empty", flagName).WithParam("--" + flagName)
}
if strings.ContainsAny(title, "\t\r\n") {
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain tabs or line breaks", flagName).WithParam("--" + flagName)
}
if err := validate.RejectControlChars(title, flagName); err != nil {
return common.FlagErrorf("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
}
if len([]rune(title)) > 100 {
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be <= 100 characters", flagName).WithParam("--" + flagName)
}
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain any of / \\ ? * [ ] :", flagName).WithParam("--" + flagName)
}
return nil
}
func validateNonNegativeInt(flagName string, value int) error {
if value < 0 {
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be >= 0, got %d", flagName, value).WithParam("--" + flagName)
}
return nil
}
@@ -287,36 +286,18 @@ func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interfac
return out
}
func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} {
if len(overlay) == 0 {
return detail
}
if detail == nil {
return overlay
}
if existing, ok := detail.(map[string]interface{}); ok {
merged := map[string]interface{}{}
for k, v := range existing {
merged[k] = v
}
for k, v := range overlay {
merged[k] = v
}
return merged
}
merged := map[string]interface{}{}
for k, v := range overlay {
merged[k] = v
}
merged["cause_detail"] = detail
return merged
}
func copySheetMoveRetryCommand(token, sheetID string, index int) string {
return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index)
}
// wrapCopySheetMoveError reports a +copy-sheet that created the new sheet but
// then failed to move it to the requested index. The copy already succeeded, so
// the recovery is to retry only the move (not the whole +copy-sheet, which would
// duplicate the sheet) — that guard and the exact retry command go into the
// hint. The underlying move error is already a typed errs.* error from
// CallAPITyped; its category/subtype/code/log_id are preserved in place
// (mirroring drive's enrichDriveSearchError) so the failure stays accurately
// classified, with only the partial-success context folded into message and hint.
func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
if strings.TrimSpace(sheetID) == "" {
return err
@@ -329,46 +310,22 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
sheetID,
retryCommand,
)
detail := map[string]interface{}{
"partial_success": true,
"failed_step": "move_copied_sheet",
"spreadsheet_token": token,
"sheet_id": sheetID,
"requested_index": index,
"retry_command": retryCommand,
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = fmt.Sprintf("%s: %s", msg, upstream)
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" {
hint = upstreamHint + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message),
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
},
Err: err,
Raw: exitErr.Raw,
}
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Message: fmt.Sprintf("%s: %v", msg, err),
Hint: hint,
Detail: detail,
},
Err: err,
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).WithHint(hint).WithCause(err)
}
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
@@ -397,7 +354,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
}
if runtime.Changed("lock-info") {
if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil {
return common.FlagErrorf("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--lock-info").WithCause(err)
}
}
@@ -405,24 +362,24 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
if hasProtectConfig {
lock := runtime.Str("lock")
if !runtime.Changed("lock") {
return common.FlagErrorf("specify --lock when updating protection settings")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --lock when updating protection settings").WithParam("--lock")
}
if runtime.Changed("lock-info") && lock != "LOCK" {
return common.FlagErrorf("--lock-info requires --lock LOCK")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--lock-info requires --lock LOCK").WithParam("--lock-info")
}
if runtime.Changed("user-ids") {
if lock != "LOCK" {
return common.FlagErrorf("--user-ids requires --lock LOCK")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --lock LOCK").WithParam("--user-ids")
}
if runtime.Str("user-id-type") == "" {
return common.FlagErrorf("--user-ids requires --user-id-type")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --user-id-type").WithParam("--user-id-type")
}
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
if err != nil {
return err
}
if len(userIDs) == 0 {
return common.FlagErrorf("--user-ids must not be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids must not be empty").WithParam("--user-ids")
}
}
}
@@ -434,7 +391,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
runtime.Changed("frozen-col-count") ||
hasProtectConfig
if !hasUpdate {
return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
}
return nil
@@ -530,7 +487,7 @@ var SheetCreateSheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
if err != nil {
return err
}
@@ -593,7 +550,7 @@ var SheetCopySheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
if err != nil {
return err
}
@@ -604,7 +561,7 @@ var SheetCopySheet = common.Shortcut{
}
if runtime.Changed("index") {
copiedSheetID, _ := out["sheet_id"].(string)
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
moveResp, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
if err != nil {
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
}
@@ -644,7 +601,7 @@ var SheetDeleteSheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
if err != nil {
return err
}
@@ -707,7 +664,7 @@ var SheetUpdateSheet = common.Shortcut{
params = map[string]interface{}{"user_id_type": userIDType}
}
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body)
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), params, body)
if err != nil {
return err
}

View File

@@ -13,8 +13,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,7 +36,7 @@ var SheetInfo = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return nil
},
@@ -55,7 +55,7 @@ var SheetInfo = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
spreadsheetData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
if err != nil {
return err
}
@@ -95,13 +95,13 @@ var SheetCreate = common.Shortcut{
if headersStr := runtime.Str("headers"); headersStr != "" {
var headers []interface{}
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
}
}
if dataStr := runtime.Str("data"); dataStr != "" {
var rows [][]interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
}
}
return nil
@@ -129,7 +129,7 @@ var SheetCreate = common.Shortcut{
if headersStr != "" {
var headers []interface{}
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
}
if len(headers) > 0 {
allRows = append(allRows, any(headers))
@@ -139,7 +139,7 @@ var SheetCreate = common.Shortcut{
if dataStr != "" {
var rows []interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
}
if len(rows) > 0 {
allRows = append(allRows, rows...)
@@ -151,7 +151,7 @@ var SheetCreate = common.Shortcut{
createData["folder_token"] = folderToken
}
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
if err != nil {
return err
}
@@ -164,7 +164,7 @@ var SheetCreate = common.Shortcut{
if err != nil {
return err
}
if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
if _, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": appendRange,
"values": allRows,
@@ -211,8 +211,11 @@ var SheetExport = common.Shortcut{
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetExportOutputPath(runtime); err != nil {
return err
}
if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sheet-id is required when --file-extension is csv").WithParam("--sheet-id")
}
return nil
},
@@ -238,10 +241,8 @@ var SheetExport = common.Shortcut{
outputPath := runtime.Str("output-path")
sheetID := runtime.Str("sheet-id")
if outputPath != "" {
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := validateSheetExportOutputPath(runtime); err != nil {
return err
}
exportData := map[string]interface{}{
@@ -253,7 +254,7 @@ var SheetExport = common.Shortcut{
exportData["sub_id"] = sheetID
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
if err != nil {
return err
}
@@ -280,7 +281,7 @@ var SheetExport = common.Shortcut{
}
if fileToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "export task timed out").WithRetryable()
}
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
@@ -298,7 +299,7 @@ var SheetExport = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapSheetsNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()
@@ -307,7 +308,7 @@ var SheetExport = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
@@ -321,3 +322,14 @@ var SheetExport = common.Shortcut{
return nil
},
}
func validateSheetExportOutputPath(runtime *common.RuntimeContext) error {
outputPath := strings.TrimSpace(runtime.Str("output-path"))
if outputPath == "" {
return nil
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output-path").WithCause(err)
}
return nil
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import "github.com/larksuite/cli/errs"
// wrapSheetsNetworkErr preserves typed boundary errors and only classifies raw
// transport failures that still surface from stream/download paths.
func wrapSheetsNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

View File

@@ -198,7 +198,7 @@ var batchOpDispatch = map[string]batchOpMapping{
// turned into a file_token. Callers must pass --image-token / --image-uri.
func rejectLocalImageInBatch(fv flagView) error {
if strings.TrimSpace(fv.Str("image")) != "" {
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
return common.ValidationErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
}
return nil
}
@@ -208,23 +208,23 @@ func rejectLocalImageInBatch(fv flagView) error {
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if sheetID == "" {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
}
if !fv.Changed("source-index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
}
if fv.Int("source-index") < 0 {
return nil, common.FlagErrorf("--source-index must be >= 0")
return nil, common.ValidationErrorf("--source-index must be >= 0")
}
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
// which would silently move the sheet to the front. Require it explicitly so
// the batch contract matches the standalone one.
if !fv.Changed("index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires index")
}
if fv.Int("index") < 0 {
return nil, common.FlagErrorf("--index must be >= 0")
return nil, common.ValidationErrorf("--index must be >= 0")
}
return map[string]interface{}{
"excel_id": token,
@@ -254,19 +254,19 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
return nil, common.ValidationErrorf("operations[%d] must be a JSON object", index)
}
scRaw, present := op["shortcut"]
if !present {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' field is required", index)
}
sc, ok := scRaw.(string)
if !ok || sc == "" {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
}
mapping, ok := batchOpDispatch[sc]
if !ok {
return nil, common.FlagErrorf(
return nil, common.ValidationErrorf(
"operations[%d]: shortcut %q not allowed in +batch-update "+
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
@@ -280,12 +280,12 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
} else {
input, ok = inputRaw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
return nil, common.ValidationErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
}
}
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
if _, has := input["operation"]; has {
return nil, common.FlagErrorf(
return nil, common.ValidationErrorf(
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
index, sc,
)
@@ -293,7 +293,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
for _, k := range reservedSubOpKeys {
if _, has := input[k]; has {
return nil, common.FlagErrorf(
return nil, common.ValidationErrorf(
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
index, sc, k,
)
@@ -302,7 +302,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// 拒绝任何额外的 sub-op 顶层 key防御未来 schema drift / 用户笔误)。
for k := range op {
if k != "shortcut" && k != "input" {
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
return nil, common.ValidationErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
}
}
fv := newMapFlagViewForCommand(sc, input)
@@ -310,14 +310,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// sub-op's scalar fields here before the translator reads them via
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
if err := fv.validateRawTypes(); err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
return nil, common.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
}
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
body, err := mapping.translate(fv, token, sheetID, sheetName)
if err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
return nil, common.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
}
return map[string]interface{}{
"tool_name": mapping.mcpToolName,
@@ -328,7 +328,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
return nil, common.ValidationErrorf("--operations must be a non-empty JSON array")
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {

View File

@@ -21,7 +21,6 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
{"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"},
{"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"},
{"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"},
{"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -38,6 +37,21 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
}
}
func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{
"csv": "a,b",
"start-cell": "C3",
"range": "A1:H17",
})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
}
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
}
}
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
// call instead of silently anchoring at the "A1" flag default. Standalone never
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it

View File

@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
@@ -453,16 +453,15 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
exitErr, ok := err.(*output.ExitError)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
t.Fatalf("error type = %T, want typed problem", err)
}
if exitErr.Detail == nil {
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
if !strings.Contains(p.Message, "shtNEW") {
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["spreadsheet_token"] != "shtNEW" {
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
if !strings.Contains(p.Hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
}
}

View File

@@ -95,7 +95,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
return common.FlagErrorf("--%s: %s", name, vErr.Error())
return common.ValidationErrorf("--%s: %s", name, vErr.Error())
}
return nil
}

View File

@@ -12,20 +12,40 @@ import (
"encoding/json"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func sheetsFlagParam(name string) string {
if strings.HasPrefix(name, "--") {
return name
}
return "--" + name
}
func sheetsInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: sheetsFlagParam(name), Reason: reason}
}
func sheetsValidationForFlag(name, format string, args ...any) *errs.ValidationError {
return common.ValidationErrorf(format, args...).WithParam(sheetsFlagParam(name))
}
func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationError {
return common.ValidationErrorf("%v", cause).WithParam(sheetsFlagParam(name)).WithCause(cause)
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
// pair shared by every sheets canonical shortcut and returns the resolved
// token. Network-free, safe to call from Validate and DryRun.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
}
return token, nil
}
@@ -33,10 +53,10 @@ func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", sheetsValidationCauseForFlag("url", err)
}
return token, nil
}
@@ -64,18 +84,18 @@ func extractSpreadsheetToken(input string) string {
// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers
// pass both through to the tool input; the server picks whichever fits.
func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) {
if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil {
if err := common.ExactlyOneTyped(runtime, "sheet-id", "sheet-name"); err != nil {
return "", "", err
}
if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" {
if err := validate.RejectControlChars(id, "sheet-id"); err != nil {
return "", "", common.FlagErrorf("%v", err)
return "", "", sheetsValidationCauseForFlag("sheet-id", err)
}
return id, "", nil
}
name := strings.TrimSpace(runtime.Str("sheet-name"))
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
return "", "", common.FlagErrorf("%v", err)
return "", "", sheetsValidationCauseForFlag("sheet-name", err)
}
return "", name, nil
}
@@ -116,18 +136,26 @@ func requireSheetSelector(sheetID, sheetName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID == "" && sheetName == "" {
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name").
WithParams(
sheetsInvalidParam("sheet-id", "required; specify at least one"),
sheetsInvalidParam("sheet-name", "required; specify at least one"),
)
}
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag("sheet-id", err)
}
} else {
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag("sheet-name", err)
}
}
return nil
@@ -152,15 +180,19 @@ func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string)
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
return common.ValidationErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName).
WithParams(
sheetsInvalidParam(idFlagName, "mutually exclusive"),
sheetsInvalidParam(nameFlagName, "mutually exclusive"),
)
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag(idFlagName, err)
}
} else if sheetName != "" {
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag(nameFlagName, err)
}
}
return nil
@@ -197,7 +229,7 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
@@ -216,11 +248,11 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
return nil, sheetsValidationForFlag(name, "--%s is required", name)
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON object", name)
return nil, sheetsValidationForFlag(name, "--%s must be a JSON object", name)
}
return m, nil
}
@@ -232,11 +264,11 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
return nil, sheetsValidationForFlag(name, "--%s is required", name)
}
a, ok := v.([]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON array", name)
return nil, sheetsValidationForFlag(name, "--%s must be a JSON array", name)
}
return a, nil
}
@@ -293,7 +325,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--border-styles must be a JSON object")
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
return m, nil
}
@@ -307,5 +339,10 @@ func requireAnyStyleFlag(runtime flagView) error {
if runtime.Str("border-styles") != "" {
return nil
}
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
WithParams(
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
)
}

View File

@@ -6,11 +6,13 @@ package sheets
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -79,6 +81,71 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
return stdout.String(), err
}
func TestSheetHelpersValidationMetadata(t *testing.T) {
t.Parallel()
t.Run("missing sheet selector reports both params", func(t *testing.T) {
t.Parallel()
err := requireSheetSelector("", "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two structured params", validationErr.Params)
}
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
}
})
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "sheets"}
cmd.Flags().String("url", "not-a-sheet-url", "")
cmd.Flags().String("spreadsheet-token", "", "")
_, err := resolveSpreadsheetToken(common.TestNewRuntimeContext(cmd, testConfig(t)))
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Param != "--url" {
t.Fatalf("param = %q, want --url", validationErr.Param)
}
})
t.Run("sheet selector control char keeps param and cause", func(t *testing.T) {
t.Parallel()
err := requireSheetSelector("bad\x00id", "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Param != "--sheet-id" {
t.Fatalf("param = %q, want --sheet-id", validationErr.Param)
}
if validationErr.Unwrap() == nil {
t.Fatalf("expected control-char validation cause to be preserved")
}
})
t.Run("invalid json flag keeps param and cause", func(t *testing.T) {
t.Parallel()
fv := newMapFlagViewForCommand("+cells-set", map[string]interface{}{"cells": "{"})
_, err := parseJSONFlag(fv, "cells")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Param != "--cells" {
t.Fatalf("param = %q, want --cells", validationErr.Param)
}
if validationErr.Unwrap() == nil {
t.Fatalf("expected JSON parse cause to be preserved")
}
})
}
// parseDryRunBody runs the shortcut in --dry-run and returns the first
// api call's body. The dry-run output format is:
//

View File

@@ -132,7 +132,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--operations is required")
return nil, common.ValidationErrorf("--operations is required")
}
if arr, ok := v.([]interface{}); ok {
return arr, nil
@@ -142,7 +142,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
return ops, nil
}
}
return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
return nil, common.ValidationErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
}
// CellsBatchSetStyle stamps one style block across many sheet-prefixed
@@ -222,7 +222,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
}
rows, cols, err := rangeDimensions(sub)
if err != nil {
return nil, common.FlagErrorf("range %q: %v", rng, err)
return nil, common.ValidationErrorf("range %q: %v", rng, err)
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
@@ -386,7 +386,7 @@ var DropdownDelete = common.Shortcut{
return err
}
if len(ranges) > 100 {
return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
return common.ValidationErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
@@ -439,7 +439,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
}
rows, cols, err := rangeDimensions(sub)
if err != nil {
return nil, common.FlagErrorf("range %q: %v", rng, err)
return nil, common.ValidationErrorf("range %q: %v", rng, err)
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
@@ -471,21 +471,21 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
return nil, common.ValidationErrorf("--ranges[%d] must be a string", i)
}
s = strings.TrimSpace(s)
if !strings.Contains(s, "!") {
return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
return nil, common.ValidationErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
}
// Validate the sheet!range shape up front so malformed entries like
// "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail
// here at Validate instead of slipping through to DryRun/Execute.
_, sub, err := splitSheetPrefixedRange(s)
if err != nil {
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
return nil, common.ValidationErrorf("--ranges[%d]: %v", i, err)
}
if _, _, err := rangeDimensions(sub); err != nil {
return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err)
return nil, common.ValidationErrorf("--ranges[%d] (%q): %v", i, s, err)
}
out = append(out, s)
}
@@ -496,7 +496,7 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) {
idx := strings.Index(rng, "!")
if idx <= 0 || idx == len(rng)-1 {
return "", "", common.FlagErrorf("range %q must use sheet!range form", rng)
return "", "", common.ValidationErrorf("range %q must use sheet!range form", rng)
}
return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil
}

View File

@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -251,7 +251,7 @@ func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec
return nil, err
}
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
return nil, common.FlagErrorf("--%s is required", spec.idFlag)
return nil, common.ValidationErrorf("--%s is required", spec.idFlag)
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
@@ -335,7 +335,7 @@ func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec
return nil, err
}
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
return nil, common.FlagErrorf("--%s is required", spec.idFlag)
return nil, common.ValidationErrorf("--%s is required", spec.idFlag)
}
input := map[string]interface{}{
"excel_id": token,
@@ -517,16 +517,16 @@ func validateSparklineUpdateItems(input map[string]interface{}) error {
}
arr, ok := raw.([]interface{})
if !ok {
return common.FlagErrorf("+sparkline-update properties.sparklines must be an array")
return common.ValidationErrorf("+sparkline-update properties.sparklines must be an array")
}
for i, item := range arr {
m, _ := item.(map[string]interface{})
if m == nil {
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
}
id, _ := m["sparkline_id"].(string)
if strings.TrimSpace(id) == "" {
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
}
}
return nil
@@ -595,20 +595,20 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
}
}
if set == 0 && requireImageSource {
return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required")
return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required")
}
if set > 1 {
return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive")
return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive")
}
name := floatImageName(runtime)
if name == "" {
return nil, common.FlagErrorf("--image-name is required")
return nil, common.ValidationErrorf("--image-name is required")
}
if !runtime.Changed("position-row") || !runtime.Changed("position-col") {
return nil, common.FlagErrorf("--position-row and --position-col are required")
return nil, common.ValidationErrorf("--position-row and --position-col are required")
}
if !runtime.Changed("size-width") || !runtime.Changed("size-height") {
return nil, common.FlagErrorf("--size-width and --size-height are required")
return nil, common.ValidationErrorf("--size-width and --size-height are required")
}
props := map[string]interface{}{
"image_name": name,
@@ -626,7 +626,9 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
// Local file: validate path safety here so --dry-run also rejects
// unsafe paths; Execute uploads it and passes the real token in.
if _, err := validate.SafeLocalFlagPath("--image", img); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
}
if uploadedImageToken != "" {
props["image_token"] = uploadedImageToken
@@ -746,7 +748,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
}
info, err := runtime.FileIO().Stat(img)
if err != nil {
return "", common.WrapInputStatError(err)
return "", common.WrapInputStatErrorTyped(err)
}
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: img,
@@ -762,7 +764,7 @@ func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string
return nil, err
}
if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" {
return nil, common.FlagErrorf("--float-image-id is required")
return nil, common.ValidationErrorf("--float-image-id is required")
}
props, err := floatImageProperties(runtime, uploadedImageToken, op == "create")
if err != nil {
@@ -882,7 +884,7 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
props := map[string]interface{}{
"range": strings.TrimSpace(runtime.Str("range")),
@@ -957,10 +959,10 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if sheetID == "" {
return nil, common.FlagErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
return nil, common.ValidationErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
@@ -1031,7 +1033,7 @@ func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if sheetID == "" {
return nil, common.FlagErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
return nil, common.ValidationErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
}
input := map[string]interface{}{
"excel_id": token,

View File

@@ -5,10 +5,9 @@ package sheets
import (
"context"
"errors"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -76,7 +75,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -108,22 +107,22 @@ func normalizeClearType(scope string) string {
// pivot-occupied A1 with cells-clear; point the agent at the object's own
// delete command instead. Non-matching errors pass through untouched.
func annotateEmbeddedBlockClearErr(err error) error {
var ee *output.ExitError
if !errors.As(err, &ee) || ee.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") {
if !strings.Contains(strings.ToLower(p.Message), "embedded block") {
return err
}
const hint = "the range overlaps an embedded object (pivot table / chart); " +
"cells-clear only clears cell values/formats and cannot delete it — " +
"delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)"
if ee.Detail.Hint == "" {
ee.Detail.Hint = hint
if p.Hint == "" {
p.Hint = hint
} else {
ee.Detail.Hint += "; " + hint
p.Hint += "; " + hint
}
return ee
return err
}
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
@@ -191,7 +190,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -345,36 +344,36 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
return nil, err
}
if !runtime.Changed("range") {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
}
if parsedDim != dimension {
want := "row numbers (e.g. \"2:10\")"
if dimension == "column" {
want = "column letters (e.g. \"A:E\")"
}
return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
return nil, common.ValidationErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
}
if !strings.Contains(rangeStr, ":") {
rangeStr = rangeStr + ":" + rangeStr
}
typ := strings.TrimSpace(runtime.Str("type"))
if typ == "" {
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
return nil, common.ValidationErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
}
if dimension == "column" && typ == "auto" {
return nil, common.FlagErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
return nil, common.ValidationErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
}
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
if typ == "pixel" && !hasSize {
return nil, common.FlagErrorf("--type pixel requires --size <px>")
return nil, common.ValidationErrorf("--type pixel requires --size <px>")
}
if typ != "pixel" && hasSize {
return nil, common.FlagErrorf("--size is only valid with --type pixel")
return nil, common.ValidationErrorf("--size is only valid with --type pixel")
}
input := map[string]interface{}{
"excel_id": token,
@@ -567,10 +566,10 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.FlagErrorf("--source-range is required")
return nil, common.ValidationErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.FlagErrorf("--target-range is required")
return nil, common.ValidationErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -609,10 +608,10 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.FlagErrorf("--source-range is required")
return nil, common.ValidationErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.FlagErrorf("--target-range is required")
return nil, common.ValidationErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -641,7 +640,7 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
// requireJSONArray runs the embedded JSON Schema for --sort-keys
// via parseJSONFlag → validateParsedJSONFlag, so each item is

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -16,34 +16,35 @@ func TestAnnotateEmbeddedBlockClearErr(t *testing.T) {
t.Parallel()
t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) {
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
Type: "api",
Message: `tool "clear_cell_range" failed: [500] can not find embedded block`,
}}
var ee *output.ExitError
if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil {
t.Fatal("expected ExitError with detail")
in := errs.NewAPIError(errs.SubtypeServerError, `tool "clear_cell_range" failed: [500] can not find embedded block`)
p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in))
if !ok {
t.Fatal("expected typed problem")
}
if !strings.Contains(ee.Detail.Hint, "+pivot-delete") {
t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint)
if !strings.Contains(p.Hint, "+pivot-delete") {
t.Errorf("hint should point at +pivot-delete, got %q", p.Hint)
}
})
t.Run("appends to existing hint", func(t *testing.T) {
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
Message: "embedded block missing", Hint: "preexisting",
}}
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") {
t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint)
in := errs.NewAPIError(errs.SubtypeServerError, "embedded block missing").WithHint("preexisting")
p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in))
if !ok {
t.Fatal("expected typed problem")
}
if !strings.HasPrefix(p.Hint, "preexisting; ") {
t.Errorf("existing hint should be preserved and appended, got %q", p.Hint)
}
})
t.Run("passes through unrelated ExitError untouched", func(t *testing.T) {
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}}
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
if out.Detail.Hint != "" {
t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint)
t.Run("passes through unrelated typed error untouched", func(t *testing.T) {
in := errs.NewAPIError(errs.SubtypeServerError, "some other failure")
p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in))
if !ok {
t.Fatal("expected typed problem")
}
if p.Hint != "" {
t.Errorf("unrelated error should not gain a hint, got %q", p.Hint)
}
})

View File

@@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
return nil
},
@@ -142,7 +142,7 @@ var CsvGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
return nil
},
@@ -484,7 +484,7 @@ var DropdownGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
return nil
},

View File

@@ -36,7 +36,7 @@ var CellsSearch = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("find")) == "" {
return common.FlagErrorf("--find is required")
return common.ValidationErrorf("--find is required")
}
return nil
},
@@ -151,10 +151,10 @@ func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[strin
return nil, err
}
if strings.TrimSpace(runtime.Str("find")) == "" {
return nil, common.FlagErrorf("--find is required")
return nil, common.ValidationErrorf("--find is required")
}
if !runtime.Changed("replacement") {
return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)")
return nil, common.ValidationErrorf("--replacement is required (pass an empty string to delete matches)")
}
input := map[string]interface{}{
"excel_id": token,

View File

@@ -164,18 +164,18 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if !runtime.Changed("position") {
return nil, common.FlagErrorf("--position is required")
return nil, common.ValidationErrorf("--position is required")
}
if !runtime.Changed("count") {
return nil, common.FlagErrorf("--count is required")
return nil, common.ValidationErrorf("--count is required")
}
position := strings.TrimSpace(runtime.Str("position"))
if _, _, err := parseA1Position(position); err != nil {
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
return nil, common.ValidationErrorf("invalid --position %q: %v", position, err)
}
count := runtime.Int("count")
if count <= 0 {
return nil, common.FlagErrorf("--count must be > 0 (got %d)", count)
return nil, common.ValidationErrorf("--count must be > 0 (got %d)", count)
}
input := map[string]interface{}{
"excel_id": token,
@@ -326,13 +326,13 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if !runtime.Changed("dimension") {
return nil, common.FlagErrorf("--dimension is required")
return nil, common.ValidationErrorf("--dimension is required")
}
if !runtime.Changed("count") {
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
return nil, common.ValidationErrorf("--count is required (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, common.FlagErrorf("--count must be >= 0")
return nil, common.ValidationErrorf("--count must be >= 0")
}
dim := runtime.Str("dimension")
count := runtime.Int("count")
@@ -361,11 +361,11 @@ func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (ma
return nil, err
}
if !runtime.Changed("range") {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if _, _, _, err := parseA1Range(rangeStr); err != nil {
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
}
input := map[string]interface{}{
"excel_id": token,
@@ -611,7 +611,7 @@ var DimMove = common.Shortcut{
}
sheetID = lookedID
}
data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
data, err := runtime.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
if err != nil {
return err
}
@@ -632,20 +632,20 @@ type dimMovePlan struct {
// target dimension matches the source. Used by both Validate and Execute.
func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) {
if !runtime.Changed("source-range") || !runtime.Changed("target") {
return nil, common.FlagErrorf("--source-range and --target are required")
return nil, common.ValidationErrorf("--source-range and --target are required")
}
src := strings.TrimSpace(runtime.Str("source-range"))
dim, startIdx, endIdx, err := parseA1Range(src)
if err != nil {
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
return nil, common.ValidationErrorf("invalid --source-range %q: %v", src, err)
}
tgt := strings.TrimSpace(runtime.Str("target"))
tgtDim, tgtIdx, err := parseA1Position(tgt)
if err != nil {
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
return nil, common.ValidationErrorf("invalid --target %q: %v", tgt, err)
}
if tgtDim != dim {
return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
}
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
}

View File

@@ -13,9 +13,9 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -122,13 +122,13 @@ var SheetCreate = common.Shortcut{
func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) {
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.FlagErrorf("--title is required")
return nil, common.ValidationErrorf("--title is required")
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.FlagErrorf("--row-count must be between 0 and 50000")
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
if n := runtime.Int("col-count"); n < 0 || n > 200 {
return nil, common.FlagErrorf("--col-count must be between 0 and 200")
return nil, common.ValidationErrorf("--col-count must be between 0 and 200")
}
input := map[string]interface{}{
"excel_id": token,
@@ -167,7 +167,7 @@ func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[s
return nil, err
}
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.FlagErrorf("--title is required")
return nil, common.ValidationErrorf("--title is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -192,7 +192,7 @@ func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) (
return nil, err
}
if !runtime.Changed("color") {
return nil, common.FlagErrorf("--color is required (empty string clears)")
return nil, common.ValidationErrorf("--color is required (empty string clears)")
}
input := map[string]interface{}{
"excel_id": token,
@@ -311,13 +311,13 @@ var SheetMove = common.Shortcut{
return err
}
if !runtime.Changed("index") {
return common.FlagErrorf("--index is required")
return common.ValidationErrorf("--index is required")
}
if runtime.Int("index") < 0 {
return common.FlagErrorf("--index must be >= 0")
return common.ValidationErrorf("--index must be >= 0")
}
if runtime.Changed("source-index") && runtime.Int("source-index") < 0 {
return common.FlagErrorf("--source-index must be >= 0")
return common.ValidationErrorf("--source-index must be >= 0")
}
return nil
},
@@ -561,7 +561,7 @@ var WorkbookCreate = common.Shortcut{
Flags: flagsFor("+workbook-create"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("title")) == "" {
return common.FlagErrorf("--title is required")
return common.ValidationErrorf("--title is required")
}
if runtime.Str("headers") != "" {
v, err := parseJSONFlag(runtime, "headers")
@@ -569,7 +569,7 @@ var WorkbookCreate = common.Shortcut{
return err
}
if _, ok := v.([]interface{}); !ok {
return common.FlagErrorf("--headers must be a JSON array")
return common.ValidationErrorf("--headers must be a JSON array")
}
}
if runtime.Str("values") != "" {
@@ -579,11 +579,11 @@ var WorkbookCreate = common.Shortcut{
}
rows, ok := v.([]interface{})
if !ok {
return common.FlagErrorf("--values must be a JSON 2D array")
return common.ValidationErrorf("--values must be a JSON 2D array")
}
for i, r := range rows {
if _, ok := r.([]interface{}); !ok {
return common.FlagErrorf("--values[%d] must be an array", i)
return common.ValidationErrorf("--values[%d] must be an array", i)
}
}
}
@@ -613,7 +613,7 @@ var WorkbookCreate = common.Shortcut{
if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" {
body["folder_token"] = v
}
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
if err != nil {
return err
}
@@ -623,7 +623,7 @@ var WorkbookCreate = common.Shortcut{
token = common.GetString(ss, "token")
}
if token == "" {
return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response")
}
result := map[string]interface{}{"spreadsheet": ss}
@@ -665,19 +665,9 @@ var WorkbookCreate = common.Shortcut{
// not. The new spreadsheet_token is surfaced in the error detail so callers can
// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only
// finding the token interpolated into a bare error string.
func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_success",
Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason),
Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it",
Detail: map[string]interface{}{
"spreadsheet_token": token,
"spreadsheet": spreadsheet,
},
},
}
func workbookCreatedButFillFailed(token string, _ interface{}, reason string) error {
return errs.NewInternalError(errs.SubtypeSDKError, "spreadsheet %s created but %s", token, reason).
WithHint("the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it")
}
// buildInitialFillInput zips --headers + --values into a single set_cell_range
@@ -765,7 +755,7 @@ var WorkbookExport = common.Shortcut{
ext = "xlsx"
}
if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
return common.FlagErrorf("--sheet-id is required when --file-extension=csv")
return common.ValidationErrorf("--sheet-id is required when --file-extension=csv")
}
return nil
},
@@ -813,13 +803,13 @@ var WorkbookExport = common.Shortcut{
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
taskData, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return err
}
ticket := common.GetString(taskData, "ticket")
if ticket == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket missing")
}
result := map[string]interface{}{
@@ -847,9 +837,9 @@ var WorkbookExport = common.Shortcut{
continue
default: // any non-zero status outside the in-progress window is a failure
if status.JobErrorMsg != "" {
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg)
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed: %s", ticket, status.JobErrorMsg)
}
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed with job_status=%d", ticket, status.JobStatus)
}
}
if fileToken == "" {
@@ -887,7 +877,7 @@ type exportTaskStatus struct {
}
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
@@ -898,7 +888,7 @@ func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (expor
}
result := common.GetMap(data, "result")
if result == nil {
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
return exportTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "export task %s: empty result", ticket)
}
js, _ := util.ToFloat64(result["job_status"])
fs, _ := util.ToFloat64(result["file_size"])
@@ -918,10 +908,10 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return "", output.ErrNetwork("download failed: %s", err)
return "", sheetsDownloadRequestError(err)
}
if apiResp.StatusCode >= 400 {
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
return "", sheetsDownloadHTTPStatusError(apiResp)
}
target := outPath
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
@@ -935,7 +925,7 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
ContentType: apiResp.Header.Get("Content-Type"),
ContentLength: int64(len(apiResp.RawBody)),
}, strings.NewReader(string(apiResp.RawBody))); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
return "", common.WrapSaveErrorTyped(err)
}
resolved, _ := runtime.FileIO().ResolvePath(target)
if resolved == "" {
@@ -944,6 +934,57 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
return resolved, nil
}
func sheetsDownloadRequestError(err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
}
func sheetsDownloadHTTPStatusError(resp *larkcore.ApiResp) error {
status := resp.StatusCode
body := strings.TrimSpace(string(resp.RawBody))
if body == "" {
body = http.StatusText(status)
}
logID := sheetsDownloadResponseLogID(resp)
if status >= http.StatusInternalServerError {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d: %s", status, body).
WithCode(status).
WithRetryable()
if logID != "" {
err = err.WithLogID(logID)
}
return err
}
if status == http.StatusTooManyRequests {
err := errs.NewAPIError(errs.SubtypeRateLimit, "download failed: HTTP %d: %s", status, body).
WithCode(status).
WithRetryable()
if logID != "" {
err = err.WithLogID(logID)
}
return err
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
err := errs.NewAPIError(subtype, "download failed: HTTP %d: %s", status, body).WithCode(status)
if logID != "" {
err = err.WithLogID(logID)
}
return err
}
func sheetsDownloadResponseLogID(resp *larkcore.ApiResp) string {
logID := strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyRequestId))
}
return logID
}
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
// id + current 0-based index. Caller is responsible for ensuring at least one
// of sheetID/sheetName is non-empty.
@@ -956,7 +997,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
}
m, ok := out.(map[string]interface{})
if !ok {
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
}
sheets, _ := m["sheets"].([]interface{})
for _, raw := range sheets {
@@ -975,7 +1016,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) {
idx, ok := util.ToFloat64(sm["index"])
if !ok {
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field")
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field")
}
return id, int(idx), nil
}
@@ -984,7 +1025,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
if target == "" {
target = sheetName
}
return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target))
return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target)
}
// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the
@@ -1001,7 +1042,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
m, ok := out.(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
}
sheets, _ := m["sheets"].([]interface{})
bestID := ""
@@ -1029,7 +1070,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
}
if bestID == "" {
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
}
return bestID, nil
}

View File

@@ -4,9 +4,14 @@
package sheets
import (
"errors"
"net/http"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -391,6 +396,92 @@ 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.

View File

@@ -15,7 +15,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -82,7 +82,7 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
cells, err := requireJSONArray(runtime, "cells")
if err != nil {
@@ -156,11 +156,11 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
@@ -218,6 +218,7 @@ var CsvPut = common.Shortcut{
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("start-cell", "range")
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
},
Validate: validateViaInput(csvPutInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -300,7 +301,10 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
return nil, err
}
if strings.TrimSpace(runtime.Str("csv")) == "" {
return nil, common.FlagErrorf("--csv is required")
return nil, common.ValidationErrorf("--csv is required")
}
if runtime.Changed("start-cell") && runtime.Changed("range") {
return nil, common.ValidationErrorf("--start-cell and --range are mutually exclusive")
}
anchor := strings.TrimSpace(runtime.Str("start-cell"))
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
@@ -311,23 +315,24 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
// collapses to its top-left cell; +csv-put pastes from the anchor and
// auto-expands, so the range's lower-right bound is irrelevant.
//
// Standalone enforces "one of --start-cell / --range" via cobra's
// MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs
// cobra, so without an explicit check the default "A1" silently wins and the
// paste lands at A1 instead of failing like the standalone command. Mirror
// the standalone contract: when --start-cell is absent, --range is mandatory.
// Standalone enforces exactly one of --start-cell / --range via cobra's
// flag groups (see PostMount). A +batch-update sub-op never runs cobra, so
// without explicit checks the default "A1" silently wins and the paste lands
// at A1 instead of failing like the standalone command. Mirror the
// standalone contract: double-set is invalid, and when --start-cell is
// absent, --range is mandatory.
if !runtime.Changed("start-cell") {
rng := strings.TrimSpace(runtime.Str("range"))
if rng == "" {
return nil, common.FlagErrorf("--start-cell or --range is required")
return nil, common.ValidationErrorf("--start-cell or --range is required")
}
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
}
if anchor == "" {
return nil, common.FlagErrorf("--start-cell is required")
return nil, common.ValidationErrorf("--start-cell is required")
}
if _, _, ok := splitCellRef(anchor); !ok {
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
return nil, common.ValidationErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
}
input := map[string]interface{}{
"excel_id": token,
@@ -398,11 +403,11 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
@@ -461,7 +466,7 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
return nil, err
}
if len(colors) > sourceSize {
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
return nil, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
dv["highlight_colors"] = colors
}
@@ -483,9 +488,9 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
switch {
case optsRaw != "" && sourceRange != "":
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
return 0, nil, common.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one")
case optsRaw == "" && sourceRange == "":
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
case optsRaw != "":
options, err := requireJSONArray(runtime, "options")
if err != nil {
@@ -498,7 +503,7 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
default: // sourceRange != ""
rows, cols, err := rangeDimensions(sourceRange)
if err != nil {
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
return 0, nil, common.ValidationErrorf("--source-range %q: %v", sourceRange, err)
}
return rows * cols, map[string]interface{}{
"type": "listFromRange",
@@ -523,7 +528,7 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
return 0, err
}
if len(colors) > sourceSize {
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
return 0, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
}
return sourceSize, nil
@@ -696,18 +701,18 @@ var CellsSetImage = common.Shortcut{
}
r := strings.TrimSpace(runtime.Str("range"))
if r == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
rows, cols, err := rangeDimensions(r)
if err != nil {
return common.FlagErrorf("--range %q: %v", r, err)
return common.ValidationErrorf("--range %q: %v", r, err)
}
if rows != 1 || cols != 1 {
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
return common.ValidationErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
}
imgPath := strings.TrimSpace(runtime.Str("image"))
if imgPath == "" {
return common.FlagErrorf("--image is required")
return common.ValidationErrorf("--image is required")
}
// Validate path safety here (not just at Execute) so --dry-run also
// rejects unsafe paths instead of giving a false-positive preview.
@@ -715,7 +720,9 @@ var CellsSetImage = common.Shortcut{
// not existence, so legitimate relative paths still dry-run cleanly;
// the Execute-time Stat below still reports a missing/unreadable file.
if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
}
return nil
},
@@ -771,16 +778,18 @@ var CellsSetImage = common.Shortcut{
}
info, err := runtime.FileIO().Stat(imgPath)
if err != nil {
return common.WrapInputStatError(err)
return common.WrapInputStatErrorTyped(err)
}
imgFile, err := runtime.FileIO().Open(imgPath)
if err != nil {
return common.WrapInputStatError(err)
return common.WrapInputStatErrorTyped(err)
}
imgCfg, _, err := image.DecodeConfig(imgFile)
imgFile.Close()
if err != nil {
return fmt.Errorf("decode image dimensions: %w", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "decode image dimensions: %s", err).
WithParam("--image").
WithCause(err)
}
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: imgPath,
@@ -809,7 +818,7 @@ var CellsSetImage = common.Shortcut{
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
if err != nil {
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
return wrapCellsSetImageWriteError(err, fileToken)
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
@@ -822,3 +831,18 @@ var CellsSetImage = common.Shortcut{
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
},
}
func wrapCellsSetImageWriteError(err error, fileToken string) error {
hint := fmt.Sprintf("image was uploaded as file_token=%s; retry only the cell write with that token or remove the uploaded media", fileToken)
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint += "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "image uploaded (file_token=%s) but cell write failed: %s", fileToken, err).
WithHint(hint).
WithCause(err)
}

View File

@@ -8,7 +8,7 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -42,7 +42,7 @@ func toolInvokePath(token string, kind ToolKind) string {
func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) {
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("encode tool input: %w", err)
return nil, errs.NewInternalError(errs.SubtypeSDKError, "encode tool input: %v", err).WithCause(err)
}
return map[string]interface{}{
"tool_name": toolName,
@@ -77,13 +77,14 @@ func callTool(
envelope, ok := raw.(map[string]interface{})
if !ok {
return nil, output.Errorf(output.ExitAPI, "tool_response",
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"tool %q: unexpected non-JSON-object response: %v", toolName, raw)
}
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"])
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
WithCode(int(code))
}
data, _ := envelope["data"].(map[string]interface{})
rawOutput, _ := data["output"].(string)
@@ -93,8 +94,8 @@ func callTool(
var out interface{}
if err := json.Unmarshal([]byte(rawOutput), &out); err != nil {
return nil, output.Errorf(output.ExitAPI, "tool_output",
"tool %q returned invalid JSON output: %v", toolName, err)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"tool %q returned invalid JSON output: %v", toolName, err).WithCause(err)
}
return out, nil
}

View File

@@ -9,7 +9,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +30,7 @@ type presentationRef struct {
func parsePresentationRef(input string) (presentationRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--presentation cannot be empty").WithParam("--presentation")
}
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
// appear as a prefix of the URL path. Substring matching previously let
@@ -38,7 +38,7 @@ func parsePresentationRef(input string) (presentationRef, error) {
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
return presentationRef{Kind: "slides", Token: token}, nil
@@ -46,13 +46,13 @@ func parsePresentationRef(input string) (presentationRef, error) {
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
return presentationRef{Kind: "wiki", Token: token}, nil
}
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
// Non-URL input must be a bare token — anything with path/query/fragment
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
// get silently accepted.
if strings.ContainsAny(raw, "/?#") {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
return presentationRef{Kind: "slides", Token: raw}, nil
}
@@ -82,7 +82,7 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
case "slides":
return ref.Token, nil
case "wiki":
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
@@ -95,14 +95,14 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "slides" {
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slides shortcuts require a slides presentation", objType).WithParam("--presentation")
}
return objToken, nil
default:
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported presentation ref kind %q", ref.Kind)
}
}
@@ -191,7 +191,7 @@ var xmlIdAttrRegex = regexp.MustCompile(`(?s)(?:^|\s)id\s*=\s*(["'])(.*?)(["'])`
func ensureXMLRootID(xmlFragment, want string) (string, error) {
m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment)
if m == nil {
return "", fmt.Errorf("no root element found in XML fragment")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "no root element found in XML fragment")
}
prefix := xmlFragment[m[2]:m[3]]
tagName := xmlFragment[m[4]:m[5]]

View File

@@ -10,7 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -33,6 +33,9 @@ var SlidesCreate = common.Shortcut{
// like wiki_move) so the pre-flight check fails fast and lark-cli's
// auth login --scope hint guides the user, instead of leaving an orphaned
// empty presentation when the in-flight upload 403s.
// NB: no drive scope here on purpose — slides creation never touches drive;
// the presentation URL is built locally (see Execute), so we don't gate a
// drive-free operation behind a drive scope.
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
@@ -42,24 +45,24 @@ var SlidesCreate = common.Shortcut{
if slidesStr := runtime.Str("slides"); slidesStr != "" {
var slides []string
if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil {
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides invalid JSON, must be an array of XML strings").WithParam("--slides")
}
if len(slides) > maxSlidesPerCreate {
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate).WithParam("--slides")
}
// Validate placeholder paths up front so we don't create a presentation
// only to fail mid-way on a missing local file.
for _, path := range extractImagePlaceholderPaths(slides) {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
return slidesInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return common.FlagErrorf("--slides @%s: must be a regular file", path)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides @%s: must be a regular file", path).WithParam("--slides")
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
path, common.FormatSize(stat.Size()))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
path, common.FormatSize(stat.Size())).WithParam("--slides")
}
}
}
@@ -125,7 +128,7 @@ var SlidesCreate = common.Shortcut{
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
@@ -141,7 +144,7 @@ var SlidesCreate = common.Shortcut{
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
@@ -165,9 +168,7 @@ var SlidesCreate = common.Shortcut{
if len(placeholders) > 0 {
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
return appendSlidesProgressHint(err, fmt.Sprintf("presentation %s was created; %d image(s) uploaded before failure", presentationID, uploaded))
}
for i := range slides {
slides[i] = replaceImagePlaceholders(slides[i], tokens)
@@ -182,7 +183,7 @@ var SlidesCreate = common.Shortcut{
var slideIDs []string
for i, slideXML := range slides {
slideData, err := runtime.CallAPI(
slideData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
@@ -191,9 +192,7 @@ var SlidesCreate = common.Shortcut{
},
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"slide %d/%d failed: %v (presentation %s was created; %d slide(s) added before failure)",
i+1, len(slides), err, presentationID, i)
return appendSlidesProgressHint(err, fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
@@ -205,29 +204,14 @@ var SlidesCreate = common.Shortcut{
}
}
// Fetch presentation URL via drive meta (best-effort)
if metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
); err == nil {
metas := common.GetSlice(metaData, "metas")
if len(metas) > 0 {
if meta, ok := metas[0].(map[string]interface{}); ok {
if url := common.GetString(meta, "url"); url != "" {
result["url"] = url
}
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
@@ -268,10 +252,10 @@ func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID str
for i, path := range paths {
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
return tokens, i, slidesInputStatError(err, fmt.Sprintf("@%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
return tokens, i, errs.NewValidationError(errs.SubtypeInvalidArgument, "@%s: must be a regular file", path).WithParam("--slides")
}
fileName := filepath.Base(path)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
@@ -279,7 +263,7 @@ func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID str
token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID)
if err != nil {
return tokens, i, fmt.Errorf("@%s: %w", path, err)
return tokens, i, fmt.Errorf("@%s: %w", path, err) //nolint:forbidigo // intermediate; preserves typed cause via %w, reclassified by appendSlidesProgressHint at the call site
}
tokens[path] = token
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -35,7 +36,6 @@ func TestSlidesCreateBasic(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -53,8 +53,10 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
if data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
// URL is built locally from the token (brand-standard host), not fetched from
// drive metas, so it is deterministic and needs no drive scope.
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -78,7 +80,6 @@ func TestSlidesCreateBotAutoGrant(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/pres_bot/members",
@@ -131,7 +132,6 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -168,7 +168,6 @@ func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -238,7 +237,6 @@ func TestSlidesCreateDefaultTitle(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -301,7 +299,6 @@ func TestSlidesCreateWithSlides(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
@@ -404,15 +401,21 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "pres_partial") {
t.Fatalf("error should contain presentation ID, got: %s", errMsg)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %v", err)
}
if !strings.Contains(errMsg, "slide 2/2") {
t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg)
// The presentation was created but a slide add failed; the recovery hint
// carries the partial-progress context (which presentation exists, how many
// slides landed) so the caller can resume without recreating.
if !strings.Contains(p.Hint, "pres_partial") {
t.Fatalf("hint should contain presentation ID, got: %s", p.Hint)
}
if !strings.Contains(errMsg, "1 slide(s) added") {
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
if !strings.Contains(p.Hint, "slide 2/2") {
t.Fatalf("hint should indicate slide 2/2 failed, got: %s", p.Hint)
}
if !strings.Contains(p.Hint, "1 slide(s) added") {
t.Fatalf("hint should report 1 slide added before failure, got: %s", p.Hint)
}
}
@@ -478,7 +481,6 @@ func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -551,7 +553,6 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -580,8 +581,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
}
}
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
// locally from the token — no drive metas/batch_query call is made, so creation
// works for users who only authorized slides scopes. The httpmock registry has no
// batch_query stub registered; if the shortcut tried to call it, the request would
// fail the test (unregistered stub), proving the URL is built without a drive call.
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -592,24 +597,15 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_url",
"xml_presentation_id": "pres_local_url",
"revision_id": 1,
},
},
})
// batch_query returns an error — URL fetch should be silently skipped
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99999,
"msg": "no permission",
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No URL",
"--title", "Local URL",
"--as", "user",
})
if err != nil {
@@ -617,11 +613,11 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_no_url" {
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
if data["xml_presentation_id"] != "pres_local_url" {
t.Fatalf("xml_presentation_id = %v, want pres_local_url", data["xml_presentation_id"])
}
if _, ok := data["url"]; ok {
t.Fatalf("did not expect url when batch_query fails")
if data["url"] != "https://www.feishu.cn/slides/pres_local_url" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_local_url", data["url"])
}
}
@@ -672,22 +668,6 @@ func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buf
return parent.Execute()
}
// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL.
func registerBatchQueryStub(reg *httpmock.Registry, token, url string) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": token, "doc_type": "slides", "title": "", "url": url},
},
},
},
})
}
// decodeSlidesCreateEnvelope parses the JSON output and returns the data map.
func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
@@ -758,7 +738,6 @@ func TestSlidesCreateWithImagePlaceholders(t *testing.T) {
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img")
slidesJSON := `[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"10\"/><img src=\"@b.png\" topLeftX=\"20\"/></data></slide>",

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// slidesInputStatError maps a FileIO.Stat error for an input image path to a
// typed validation error, prefixing the caller's context message. Both path
// validation failures and other stat errors are user-actionable input problems
// (exit code 2). Already-typed errors are not expected here (Stat returns raw
// fs errors), so this always classifies as validation.
func slidesInputStatError(err error, msg string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: unsafe file path: %s", msg, err).WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
}
// appendSlidesProgressHint preserves err's typed classification (per
// ERROR_CONTRACT.md "propagate typed errors unchanged") and appends an
// orchestration-progress hint — e.g. "presentation was created; N image(s)
// uploaded before failure" — so a failure mid-sequence still tells the caller
// what partial state exists. An unclassified error (e.g. surfaced from a shared
// helper boundary before it can be classified) falls back to a typed internal
// error carrying the hint.
func appendSlidesProgressHint(err error, hint string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
if p.Hint != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"path/filepath"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -86,15 +86,15 @@ var SlidesMediaUpload = common.Shortcut{
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return slidesInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size()))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size())).WithParam("--file")
}
fileName := filepath.Base(filePath)
@@ -124,7 +124,7 @@ var SlidesMediaUpload = common.Shortcut{
// because the multipart upload API does not accept parent_type=slide_file.
func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) {
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
fileName, common.FormatSize(fileSize))
}
parent := presentationID

Some files were not shown because too many files have changed in this diff Show More