Compare commits

..

6 Commits

Author SHA1 Message Date
shanglei
3b6aa7dc6a perf(startup): resolve brand & strict-mode without decrypting the app secret
Building the command tree resolved brand (auth login --domain help) and
strict mode (per-command identity-flag registration via ResolveStrictMode)
through f.Config(), which decrypts the app secret. On macOS that is a
Keychain (securityd IPC) read on every invocation -- even --help/--version/
schema/completion, which never use the secret.

brand and strict mode are plain config.json fields, so route them through a
secret-free metadata path:
- credential: add (*DefaultAccountProvider).ResolveMeta and a cached
  (*CredentialProvider).ResolveMeta returning brand + SupportedIdentities,
  via an optional metaResolver capability; external providers fall back to
  ResolveAccount (they do not touch the keychain).
- cmdutil: ResolveStrictMode now uses ResolveMeta; add Factory.ConfigBrand.
- auth login help text and shortcut registration read ConfigBrand.

The app secret is still decrypted on demand when a command actually calls
the API (auth status etc. unchanged). Measured on macOS (GOMAXPROCS=1):
lark-cli --help 35.4ms -> 21.4ms (-40%); no behavior change on Linux/Windows
(local secret backends are cheap).
2026-06-10 17:46:03 +08:00
shanglei
ed63e12725 fix(registry): drop unused typedInitialized var; refresh OrderedProps doc
typedInitialized was only assigned (in resetTyped) and never read, so
staticcheck's U1000 (golangci `unused`) flagged it and would block the
lint gate. Remove the field and its assignment.

Also refresh the OrderedProps comment: the static-registry migration
removed the meta_data.json natural-order machinery, so Order is now
populated alphabetically (orderedKeys). The comment still claimed
natural-order preservation. No behavior change.
2026-06-10 14:46:18 +08:00
shanglei
761aa55cbf feat(registry): carry affordance through the typed registry
The static-meta migration modeled only the method fields it explicitly
declared, but the schema assembler still reads method["affordance"] to
build _meta.affordance. metaschema.Method had no Affordance field and
MethodToMap did not backfill it, so any method whose meta_data.json entry
carries an affordance overlay would silently lose _meta.affordance in
`schema --format json` (the embedded-JSON loader preserved it via the
untyped map). Local meta currently has zero affordance entries, so no
existing test caught it.

Model affordance in the typed registry:
- metaschema.Method gains Affordance plus Affordance/AffordanceExample types
- gen.go emits the affordance literal (static, zero-alloc)
- MethodToMap rebuilds method["affordance"] so parseAffordance keeps working

schema JSON mode reads EmbeddedSpec (static baseline via ServiceToMap),
which bypasses the remote overlay for determinism, so the remote/wire and
MapToMethod paths -- never affordance consumers -- are left untouched.

Add TestBuildMeta_AffordanceThroughTypedRegistry covering
Method -> MethodToMap -> buildMeta -> _meta.affordance.
2026-06-10 14:13:56 +08:00
shanglei
2098c3c412 perf(registry): drop embedded meta_data.json and the larkmeta build tag
The startup baseline now comes solely from the generated static Go registry
(metastatic.Registry), wired into the stub-declared Registry via a package-level
var plus an init() struct-header copy. No build tag, no committed generated
file, and zero startup allocation is preserved.

- gen.go emits a tag-free `var registryData` + `func init()` instead of a
  //go:build larkmeta top-level `var Registry`; stub.go declares Registry
  unconditionally so the package always compiles
- fetch_meta.py regenerates the static registry after fetching, so every build
  and CI step that fetches also produces it (no separate gen step, no CI change)
- remove the //go:embed meta_data.json baseline and the JSON parse fallback;
  meta_data.json is now only the build-time input to the generator
- EmbeddedSpec/EmbeddedServiceNames read the static baseline; drop the schema
  key-order machinery so envelope field order is alphabetical (JSON Schema
  property order is not semantic; parameterOrder for positional args is intact)
- drop -tags larkmeta from Makefile, .goreleaser.yml, and build-pkg-pr-new.sh

Command tree is byte-identical (8092 lines). registry/schema/cmd unit tests,
the zero-alloc bench, and the e2e dry-run suite all pass.
2026-06-09 18:36:58 +08:00
shanglei
4215ad9908 feat(registry): build command tree from static typed meta (larkmeta)
Migrate the startup command-tree build path to read compile-time static
Go structs (metastatic.Registry, behind -tags larkmeta) instead of parsing
the 1.9MB meta_data.json into map[string]interface{} (~170K heap objects)
on every invocation.

- typed.go: typed baseline + remote-overlay layer. Reads static data under
  -tags larkmeta (zero parse / zero alloc); falls back to parsing the
  embedded meta_data.json once for builds without the tag (dev/test). A lazy
  typed->map shim (ServiceToMap/MethodToMap) keeps execution-path consumers
  working unchanged.
- service.go: registration chain and NewCmdServiceMethod read typed structs
  directly; map wrappers convert via MapToService/MapToMethod for existing
  tests; method RunE materializes opts.Spec/opts.Method lazily, never at
  startup.
- loader.go/remote.go: baseline and cached remote overlay decode into the
  typed shape; runtime refresh preserved.
- build pipeline: Makefile, goreleaser and pkg-pr-new run the generator and
  build with -tags larkmeta so shipped binaries carry the static data.

Reading the full static registry is 0 B/op, 0 allocs/op; the built command
tree is byte-identical to the JSON-parsed tree (verified via a canonical
tree dump in both modes).
2026-06-09 16:49:40 +08:00
shanglei
0b305a2248 feat(registry): typed meta schema + static-data generator (larkmeta tag)
Render the embedded command spec as static Go data (metaschema types + metastatic generator and stub) to eliminate the startup JSON parse and allocation. Behind the larkmeta build tag; the runtime still uses the JSON path (not yet wired). A validation test confirms the static data matches the JSON parse (13 services / 70 resources / 215 methods / 4233 fields) and that reads allocate nothing. Generated meta_data_gen.go is gitignored, like meta_data.json.
2026-06-09 15:37:30 +08:00
122 changed files with 2730 additions and 2703 deletions

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ 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/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/)
- 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/)
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/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)
- 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)
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/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/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -2,6 +2,8 @@ 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,46 +2,6 @@
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
@@ -1066,7 +1026,6 @@ 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,6 +12,9 @@ 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,10 +72,12 @@ 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.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
if f != nil && f.ConfigBrand != nil {
if b, ok := f.ConfigBrand(); ok {
helpBrand = b
}
}
available := sortedKnownDomains(helpBrand)
@@ -296,11 +298,10 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 2: Show user code and verification URL.
// 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.
// 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).
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -318,9 +319,7 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
// Step 3: Poll for token
@@ -407,11 +406,10 @@ 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 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 {
// 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 {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)

View File

@@ -0,0 +1,87 @@
// 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,6 +18,7 @@ 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"
@@ -30,74 +31,56 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
for _, spec := range registry.TypedServices() {
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
continue
}
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerServiceWithContext(ctx, parent, spec, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
svc := registry.MapToService(spec)
svc.Resources = registry.MapToResources(resources)
registerServiceWithContext(context.Background(), parent, svc, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
specDesc := registry.GetServiceDescription(spec.Name, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
specDesc = spec.Description
}
// Find existing service command or create one
var svc *cobra.Command
for _, c := range parent.Commands() {
if c.Name() == specName {
if c.Name() == spec.Name {
svc = c
break
}
}
if svc == nil {
svc = &cobra.Command{
Use: specName,
Use: spec.Name,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
for _, resource := range spec.Resources {
registerResourceWithContext(ctx, svc, spec, resource, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, resource metaschema.Resource, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
Use: resource.Name,
Short: resource.Name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
for _, method := range resource.Methods {
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
}
}
@@ -125,31 +108,36 @@ type ServiceMethodOptions struct {
FileFields []string // auto-detected file field names from metadata
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
// 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
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, method metaschema.Method, 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.
// NewCmdServiceMethod creates a command for a dynamically registered service
// method from map specs (kept for tests; converts to typed internally).
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
return NewCmdServiceMethodWithContext(context.Background(), f, registry.MapToService(spec), registry.MapToMethod(name, method), name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
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)
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
}
var asStr string
@@ -159,6 +147,10 @@ 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)
@@ -188,7 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
fileFields := detectFileFieldsTyped(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
@@ -200,10 +192,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
// meta_data.json carries no per-method tips; SetTips(nil) matches prior behavior.
cmdutil.SetTips(cmd, nil)
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
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))
}
return cmd

View File

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

View File

@@ -30,10 +30,11 @@ type InvocationContext struct {
}
type Factory struct {
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
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
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
@@ -151,11 +152,14 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if f.Credential == nil {
return core.StrictModeOff
}
acct, err := f.Credential.ResolveAccount(ctx)
if err != nil || acct == nil {
// 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 {
return core.StrictModeOff
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
ids := extcred.IdentitySupport(supported)
switch {
case ids.BotOnly():
return core.StrictModeBot

View File

@@ -78,6 +78,18 @@ 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,7 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
)
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
Config: func() (*core.CliConfig, error) { return config, nil },
ConfigBrand: func() (core.LarkBrand, bool) {
if config != nil {
return config.Brand, true
}
return "", false
},
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,6 +21,14 @@ 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)
@@ -141,6 +149,11 @@ type CredentialProvider struct {
accountErr error
selectedSource credentialSource
metaOnce sync.Once
metaBrand core.LarkBrand
metaIdents uint8
metaOK bool
hintOnce sync.Once
hint *IdentityHint
hintErr error
@@ -172,6 +185,44 @@ 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,6 +76,23 @@ 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,72 +19,32 @@ import (
//go:embed scope_priorities.json scope_overrides.json
var registryFS embed.FS
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
var embeddedMetaJSON []byte
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
// that need to parse key order or other JSON-level structure not exposed by
// LoadFromMeta (which loses map insertion order).
func EmbeddedMetaJSON() []byte {
return embeddedMetaJSON
}
var (
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
embeddedServiceNames []string // sorted
embeddedParseOnce sync.Once
)
// 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.
// 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{} {
parseEmbeddedServices()
return embeddedServicesMap[serviceName]
if svc, ok := baselineServiceByName(serviceName); ok {
return ServiceToMap(svc)
}
return nil
}
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
// Returns a defensive copy — callers must not mutate the package-level slice.
// EmbeddedServiceNames returns the embedded baseline service names, sorted
// (no remote overlay).
func EmbeddedServiceNames() []string {
parseEmbeddedServices()
out := make([]string, len(embeddedServiceNames))
copy(out, embeddedServiceNames)
svcs := baselineServices()
out := make([]string, 0, len(svcs))
for _, s := range svcs {
out = append(out, s.Name)
}
sort.Strings(out)
return out
}
var (
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
mergedProjectList []string // sorted project names
embeddedVersion string // version from embedded meta_data.json
initOnce sync.Once
embeddedVersion string // baseline data version (from the static registry)
initOnce sync.Once
)
// Init initializes the registry with default brand (feishu).
@@ -101,55 +61,27 @@ func Init() {
func InitWithBrand(brand core.LarkBrand) {
initOnce.Do(func() {
configuredBrand = brand
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
loadEmbeddedIntoMerged()
// 2. Remote overlay
// 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.
if remoteEnabled() && cacheWritable() {
// Check if brand changed since last cache
meta, metaErr := loadCacheMeta()
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
if !brandChanged {
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
_ = loadCachedTyped()
}
if len(mergedServices) == 0 || brandChanged {
// No data at all or brand changed — must sync fetch
if !hasTypedData() || brandChanged {
// No data at all (e.g. stub build, no cache) or brand changed.
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
@@ -226,7 +158,11 @@ func CollectAllScopesFromMeta(identity string) []string {
// It returns data from the merged registry (embedded + cached remote overlay).
func LoadFromMeta(project string) map[string]interface{} {
Init()
return mergedServices[project]
svc, ok := typedServiceByName(project)
if !ok {
return nil
}
return ServiceToMap(svc)
}
// ListFromMetaProjects lists available service project names (sorted).
@@ -234,7 +170,7 @@ func LoadFromMeta(project string) map[string]interface{} {
//go:noinline
func ListFromMetaProjects() []string {
Init()
return mergedProjectList
return typedServiceNames()
}
// DefaultScopeScore is the score assigned to scopes not in the priorities table.

View File

@@ -1,20 +0,0 @@
// 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

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

View File

@@ -0,0 +1,99 @@
// 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

@@ -0,0 +1,255 @@
// 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

@@ -0,0 +1,15 @@
// 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

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

579
internal/registry/typed.go Normal file
View File

@@ -0,0 +1,579 @@
// 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,290 +4,14 @@
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
@@ -501,10 +225,6 @@ 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.
@@ -611,8 +331,6 @@ 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",
@@ -738,27 +456,11 @@ 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), 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.
// envelope).
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
@@ -836,35 +538,10 @@ func walkMethods(resources map[string]interface{}, parentPath []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
// 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 {
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)

View File

@@ -7,10 +7,12 @@ 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
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
// We only assert key-set membership, not absolute order — the upstream
// meta_data API does not guarantee a stable JSON key sequence across
// fetches, so hard-coding the order makes CI flaky. Order preservation
// from input to output is tested separately in TestBuildInputSchema_*.
order := lookupKeyOrder("im", []string{"reactions"}, "list")
if order == nil {
t.Fatal("expected key order for im.reactions.list, got nil")
}
wantParams := map[string]bool{
"message_id": true, "reaction_type": true, "page_token": true,
"page_size": true, "user_id_type": true,
}
if got, want := len(order.Parameters), len(wantParams); got != want {
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
}
for _, k := range order.Parameters {
if !wantParams[k] {
t.Errorf("unexpected parameter key %q", k)
}
}
// im.reactions.list 是 GET没有 requestBody
if len(order.RequestBody) != 0 {
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
}
}
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
order := lookupKeyOrder("im", []string{"images"}, "create")
if order == nil {
t.Fatal("expected key order for im.images.create, got nil")
}
wantBody := map[string]bool{"image_type": true, "image": true}
if got, want := len(order.RequestBody), len(wantBody); got != want {
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
}
for _, k := range order.RequestBody {
if !wantBody[k] {
t.Errorf("unexpected requestBody key %q", k)
}
}
}
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
// 远端缓存的命令(不在 embedded 内)查不到 key order返回 nil 走字母序兜底
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
if order != nil {
t.Errorf("expected nil for unknown path, got %+v", order)
}
}
func TestConvertProperty_BasicTypes(t *testing.T) {
tests := []struct {
name string
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
func TestBuildInputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -313,16 +260,15 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
// 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)
}
}
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -382,9 +328,6 @@ 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)
@@ -413,9 +356,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if _, ok := is.Properties.Map["yes"]; ok {
@@ -425,9 +365,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
os := buildOutputSchema(method)
@@ -613,6 +550,45 @@ 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"},
@@ -634,7 +610,6 @@ 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,9 +83,13 @@ type AffordanceCase struct {
Command string `json:"command"`
}
// 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.
// 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).
type OrderedProps struct {
Order []string
Map map[string]Property

View File

@@ -17,20 +17,13 @@ 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,20 +18,14 @@ 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/wiki/",
"shortcuts/im/",
}
// 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/unmigrated/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/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/unmigrated/sample.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
@@ -944,19 +944,11 @@ 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 {
@@ -1005,34 +997,6 @@ 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
@@ -1042,7 +1006,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.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.49",
"version": "1.0.48",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -6,6 +6,7 @@ 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,6 +63,19 @@ 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"],
@@ -71,27 +84,29 @@ def main():
help="force refresh from remote even if local file exists")
args = parser.parse_args()
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)
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
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", 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")
with open(OUT_PATH, "w") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")
# 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()
if __name__ == "__main__":

View File

@@ -676,10 +676,30 @@ 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.
// 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.
// 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.
func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil

View File

@@ -6,8 +6,24 @@ 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 TestResolveOpenIDsTyped_Empty(t *testing.T) {
func TestResolveOpenIDs_Empty(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDsTyped("--user-ids", nil, rt)
out, err := ResolveOpenIDs("--user-ids", nil, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -28,9 +28,21 @@ func TestResolveOpenIDsTyped_Empty(t *testing.T) {
}
}
func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
func TestResolveOpenIDs_ExpandsMeAndDedups(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
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)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -40,11 +52,22 @@ func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
}
}
func TestResolveOpenIDsTyped_DedupIsCaseInsensitive(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) {
rt := resolveOpenIDsTestRuntime("ou_self")
// Same underlying open_id with three case variants — should collapse to
// one entry, preserving the first-occurrence form.
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
out, err := ResolveOpenIDs("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

View File

@@ -5,6 +5,8 @@ package common
import (
"strings"
"github.com/larksuite/cli/internal/output"
)
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
@@ -40,6 +42,17 @@ 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

@@ -1,78 +0,0 @@
// 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

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

@@ -1,125 +0,0 @@
// 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,7 +15,6 @@ 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"
@@ -81,6 +80,12 @@ 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"`
@@ -211,17 +216,19 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
if err != nil {
return err
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
respData, err := decodeSearchUserAPIData(data)
if err != nil {
return err
if apiResp.StatusCode != http.StatusOK {
return output.ErrAPI(apiResp.StatusCode, http.StatusText(apiResp.StatusCode), string(apiResp.RawBody))
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
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)
out := searchUserResponse{Users: users, HasMore: hasMore}
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
@@ -238,20 +245,6 @@ 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"
}
@@ -380,74 +373,52 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
func validateSearchUser(runtime *common.RuntimeContext) error {
if !hasAnySearchInput(runtime) {
return common.ValidationErrorf(
return common.FlagErrorf(
"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.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"},
)
return common.FlagErrorf("--query and --queries are mutually exclusive")
}
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
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"},
)
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
}
queries := parseAndDedupQueries(queriesRaw)
if len(queries) == 0 {
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
WithParam("--queries")
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
}
if len(queries) > maxFanoutQueries {
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
WithParam("--queries")
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
}
for _, q := range queries {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars).
WithParam("--queries")
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
}
}
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars).
WithParam("--query")
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
}
}
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
if err != nil {
return err
}
if len(ids) == 0 {
return common.ValidationErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw).
WithParam("--user-ids")
return common.FlagErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw)
}
if len(ids) > maxSearchUserUserIDs {
return common.ValidationErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs).
WithParam("--user-ids")
return common.FlagErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs)
}
for _, id := range ids {
if _, err := common.ValidateUserIDTyped("--user-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return err
}
}
@@ -458,16 +429,15 @@ 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.ValidationErrorf(
return common.FlagErrorf(
"--%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.ValidationErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize).
WithParam("--page-size")
return common.FlagErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize)
}
return nil
}
@@ -503,7 +473,7 @@ func buildSearchUserBody(runtime *common.RuntimeContext) (*searchUserAPIRequest,
hasFilter := false
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
if err != nil {
return nil, err
}

View File

@@ -5,6 +5,7 @@ package contact
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -46,7 +47,7 @@ type fanoutResult struct {
Users []searchUser
HasMore bool
ErrMsg string // empty = success
Err error // original failure, kept for typed all-failed propagation
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
@@ -66,7 +67,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 fanoutErrorResult(index, query, err)
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
}
body := &searchUserAPIRequest{Query: query}
@@ -81,29 +82,38 @@ 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 fanoutErrorResult(index, query, err)
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}
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
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)}
}
respData, err := decodeSearchUserAPIData(data)
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}
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
users, hasMore := projectUsers(resp.Data, 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"`
@@ -136,7 +146,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
}
failed := 0
var firstErrMsg, firstErrQuery string
var firstErr error
var firstErrCode int
for i, r := range indexed {
out.Queries = append(out.Queries, querySummary{
Query: queries[i],
@@ -148,7 +158,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
if firstErrMsg == "" {
firstErrMsg = r.ErrMsg
firstErrQuery = queries[i]
firstErr = r.Err
firstErrCode = r.ErrCode
}
continue
}
@@ -159,7 +169,18 @@ 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)
return nil, contactFanoutAllFailedError(firstErr, msg)
// 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 out, nil
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -15,10 +16,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"
)
@@ -253,16 +254,6 @@ 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())
@@ -488,26 +479,6 @@ 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()
@@ -533,20 +504,6 @@ 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 {
@@ -1054,13 +1011,6 @@ 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)
}
@@ -1082,15 +1032,8 @@ 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)
}
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)
if got.ErrCode != 503 {
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
}
}
@@ -1137,16 +1080,6 @@ 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},
@@ -1203,7 +1136,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 typed error must carry an actionable
// panic, ctx-canceled), the returned ExitError 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{
@@ -1214,38 +1147,28 @@ func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
if err == nil {
t.Fatalf("expected error when all queries failed")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if p.Category != errs.CategoryInternal {
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryInternal)
if exitErr.Detail == nil {
t.Fatalf("expected Detail, got nil")
}
if p.Hint == "" {
if exitErr.Detail.Hint == "" {
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
}
if !strings.Contains(p.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", p.Hint)
if !strings.Contains(exitErr.Detail.Hint, "retry") {
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
}
}
// 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)
// 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)
// 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",
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),
},
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
}
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
if err == nil {
@@ -1254,16 +1177,6 @@ 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) {
@@ -1307,37 +1220,6 @@ 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,8 +11,6 @@ import (
"regexp"
"runtime"
"strings"
"github.com/larksuite/cli/errs"
)
// readClipboardImageBytes reads the current clipboard image and returns the
@@ -37,13 +35,13 @@ func readClipboardImageBytes() ([]byte, error) {
case "linux":
data, err = readClipboardLinux()
default:
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS)
return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS)
}
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
return nil, fmt.Errorf("clipboard contains no image data")
}
return data, nil
}
@@ -93,9 +91,9 @@ func readClipboardDarwin() ([]byte, error) {
}
if stderrText != "" {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText)
return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText)
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
return nil, fmt.Errorf("clipboard contains no image data")
}
// runOsascript invokes osascript with a single AppleScript expression and
@@ -190,14 +188,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") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
return nil, fmt.Errorf("odd hex length")
}
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) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
return nil, fmt.Errorf("invalid hex char at %d", i)
}
b[i/2] = byte(hi<<4 | lo)
}
@@ -239,12 +237,12 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
if msg == "" {
msg = err.Error()
}
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg)
return nil, fmt.Errorf("clipboard read failed (%s)", msg)
}
b64 := strings.TrimSpace(string(out))
data, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr)
return nil, fmt.Errorf("clipboard image decode failed: %w", decErr)
}
return data, nil
}
@@ -327,15 +325,15 @@ func readClipboardLinux() ([]byte, error) {
foundTool = true
out, err := exec.Command(t.name, t.args...).Output()
if err != nil {
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err)
lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err)
continue
}
if len(out) == 0 {
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name)
lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name)
continue
}
if t.validatePNG && !hasPNGMagic(out) {
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name)
lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name)
continue
}
return out, nil
@@ -344,8 +342,8 @@ func readClipboardLinux() ([]byte, error) {
if foundTool && lastErr != nil {
return nil, lastErr
}
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 "+
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 " +
"(apt, dnf, pacman, apk, brew, etc.).")
}

View File

@@ -1,16 +0,0 @@
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
return output.ErrValidation("%s", err)
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
return output.ErrValidation("unsafe output path: %s", 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 wrapDocNetworkErr(err, "download failed: %v", err)
return output.ErrNetwork("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
return output.ErrValidation("unsafe output path: %s", err)
}
}
// Overwrite check on final path (after extension detection)
if !overwrite {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
}
}
@@ -102,7 +102,7 @@ var DocMediaDownload = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorTyped(err)
return common.WrapSaveErrorByCategory(err, "io")
}
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required")
return common.FlagErrorf("one of --file or --from-clipboard is required")
}
if filePath != "" && fromClipboard {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive")
return common.FlagErrorf("--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 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")
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
}
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis must not be blank or whitespace-only").WithParam("--selection-with-ellipsis")
return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only")
}
if runtime.Bool("before") && trimmedSelection == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--before requires --selection-with-ellipsis").WithParam("--before")
return output.ErrValidation("--before requires --selection-with-ellipsis")
}
if view := runtime.Str("file-view"); view != "" {
if _, ok := fileViewMap[view]; !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-view value %q, expected one of: card | preview | inline", view).WithParam("--file-view")
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
}
if runtime.Str("type") != "file" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-view only applies when --type=file").WithParam("--file-view")
return output.ErrValidation("--file-view only applies when --type=file")
}
}
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image")
return output.ErrValidation("--width/--height only apply when --type=image")
}
if widthChanged && runtime.Int("width") <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width")
return output.ErrValidation("--width must be a positive integer")
}
if heightChanged && runtime.Int("height") <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be a positive integer").WithParam("--height")
return output.ErrValidation("--height must be a positive integer")
}
const maxDimension = 10000
if widthChanged && runtime.Int("width") > maxDimension {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must not exceed %d pixels", maxDimension).WithParam("--width")
return output.ErrValidation("--width must not exceed %d pixels", maxDimension)
}
if heightChanged && runtime.Int("height") > maxDimension {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must not exceed %d pixels", maxDimension).WithParam("--height")
return output.ErrValidation("--height must not exceed %d pixels", maxDimension)
}
return nil
},
@@ -269,10 +269,10 @@ var DocMediaInsert = common.Shortcut{
} else {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatErrorTyped(err, "file not found")
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
return output.ErrValidation("file must be a regular file: %s", filePath)
}
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.CallAPITyped("GET",
rootData, err := runtime.CallAPI("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.CallAPITyped("POST",
createData, err := runtime.CallAPI("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 errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create block: no block_id returned")
return output.Errorf(output.ExitAPI, "api_error", "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.CallAPITyped("DELETE",
_, err := runtime.CallAPI("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(errs.NewValidationError(errs.SubtypeInvalidArgument,
return withRollbackWarning(output.ErrValidation(
"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(errs.NewValidationError(errs.SubtypeInvalidArgument,
return withRollbackWarning(output.ErrValidation(
"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.CallAPITyped("PATCH",
if _, err := runtime.CallAPI("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 "", 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")
return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPITyped(
data, err := runtime.CallAPI(
"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 "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
}
if objType != "docx" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
return objToken, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
return "", output.ErrValidation("docs +media-insert only supports docx documents")
}
}
@@ -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, errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to query document root block")
return "", 0, nil, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block")
}
parentBlockID = fallbackBlockID
@@ -653,10 +653,12 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
matches := common.GetSlice(result, "matches")
if len(matches) == 0 {
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")
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",
)
}
if len(matches) > 1 {
// Silently picking the first match surprises users whose selection appears
@@ -680,7 +682,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
}
}
if anchorBlockID == "" {
return 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
return 0, output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
}
parentBlockID := common.GetString(matchMap, "parent_block_id")
@@ -738,7 +740,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.CallAPITyped("GET",
data, err := runtime.CallAPI("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s",
validate.EncodePathSegment(documentID), validate.EncodePathSegment(cur)),
nil, nil)
@@ -755,10 +757,12 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
walkDepth++
}
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")
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",
)
}
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
return output.ErrValidation("%s", err)
}
// Early path validation before API call (final validation after auto-extension below)
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
return output.ErrValidation("unsafe output path: %s", 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 wrapDocNetworkErr(err, "preview failed: %v", err)
return output.ErrNetwork("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
return output.ErrValidation("unsafe output path: %s", err)
}
}
// Overwrite check on final path (after extension detection)
if !overwrite {
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
}
}
@@ -90,7 +90,7 @@ var DocMediaPreview = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorTyped(err)
return common.WrapSaveErrorByCategory(err, "io")
}
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.WrapInputStatErrorTyped(err, "file not found")
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
return output.ErrValidation("file must be a regular file: %s", filePath)
}
fileName := filepath.Base(filePath)

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import (
"encoding/json"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
return documentRef{}, output.ErrValidation("--doc cannot be empty")
}
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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw)
}
if strings.ContainsAny(raw, "/?#") {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx token or a wiki URL", raw).WithParam("--doc")
return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
}
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}.
// 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.
// 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.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.CallAPITyped(method, apiPath, nil, body)
return runtime.DoAPIJSONWithLogID(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 "", errs.NewInternalError(errs.SubtypeUnknown, "failed to marshal upload extra data: %v", err).WithCause(err)
return "", output.Errorf(output.ExitInternal, "internal_error", "failed to marshal upload extra data: %v", err)
}
return string(extra), nil
}

View File

@@ -6,7 +6,6 @@ package doc
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -66,7 +65,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", "--api-version")
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API")
}
var used []string
@@ -88,12 +87,11 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF
if len(replacements) > 0 {
detail += "; " + strings.Join(replacements, "; ")
}
return docsV2OnlyError(shortcut, detail, used[0])
return docsV2OnlyError(shortcut, detail)
}
func docsV2OnlyError(shortcut, detail, param string) error {
err := errs.NewValidationError(
errs.SubtypeInvalidArgument,
func docsV2OnlyError(shortcut, detail string) error {
return common.FlagErrorf(
"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,
@@ -102,8 +100,4 @@ func docsV2OnlyError(shortcut, detail, param string) error {
docsMDSkillReadCommand,
docsHelpCommandForShortcut(shortcut),
)
if param != "" {
err = err.WithParam(param)
}
return err
}

View File

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

View File

@@ -5,6 +5,7 @@ package markdown
import (
"context"
"errors"
"fmt"
"io"
"regexp"
@@ -13,7 +14,6 @@ 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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
return output.ErrValidation("%s", 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 markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
return output.ErrValidation("unsafe file path: %s", err)
}
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
return err
}
}
if spec.ContextLines < 0 {
return markdownValidationParamError("--context-lines", "--context-lines must be >= 0")
return output.ErrValidation("--context-lines must be >= 0")
}
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
return markdownValidationParamError("--format", "markdown +diff only supports --format json or pretty")
return output.ErrValidation("markdown +diff only supports --format json or pretty")
}
if spec.FilePath == "" {
if spec.FromVersion == "" && spec.ToVersion == "" {
return markdownValidationError("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
}
if spec.FromVersion == "" && spec.ToVersion != "" {
return markdownValidationParamError("--to-version", "--to-version requires --from-version")
return common.FlagErrorf("--to-version requires --from-version")
}
return nil
}
if spec.ToVersion != "" {
return markdownValidationParamError("--to-version", "--to-version is not supported together with --file")
return common.FlagErrorf("--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 markdownValidationParamError(flagName, "%s cannot be empty", flagName)
return output.ErrValidation("%s cannot be empty", flagName)
}
if !markdownDiffVersionRe.MatchString(value) {
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
return output.ErrValidation("%s must be a numeric version string", flagName)
}
return nil
}
@@ -178,16 +178,17 @@ 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.WrapInputStatErrorTyped(err)
return "", common.WrapInputStatError(err)
}
defer f.Close()
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return "", err
}
return "", markdownValidationError("cannot read file: %s", err).WithCause(err)
return "", output.ErrValidation("cannot read file: %s", err)
}
return string(payload), nil
}
@@ -198,7 +199,7 @@ func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
return nil, err
}
if len(payload) > markdownDiffMaxContentBytes {
return nil, markdownValidationError("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
}
return payload, nil
}

View File

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

View File

@@ -1,49 +0,0 @@
// 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,6 +14,7 @@ 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"
)
@@ -34,14 +35,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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath == "" {
return nil
}
if _, err := validate.SafeOutputPath(outputPath); err != nil {
return markdownValidationParamError("--output", "unsafe output path: %s", err).WithCause(err)
return output.ErrValidation("unsafe output path: %s", err)
}
return nil
},
@@ -66,7 +67,7 @@ var MarkdownFetch = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return wrapMarkdownDownloadError(err)
return output.ErrNetwork("download failed: %s", err)
}
defer resp.Body.Close()
@@ -74,7 +75,7 @@ var MarkdownFetch = common.Shortcut{
if outputPath == "" {
payload, err := io.ReadAll(resp.Body)
if err != nil {
return wrapMarkdownDownloadError(err)
return output.ErrNetwork("download failed: %s", err)
}
out := map[string]interface{}{
"file_token": fileToken,
@@ -92,7 +93,7 @@ var MarkdownFetch = common.Shortcut{
outputPath = filepath.Join(outputPath, fileName)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
return markdownValidationParamError("--output", "output file already exists: %s (use --overwrite to replace)", outputPath)
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
@@ -100,7 +101,7 @@ var MarkdownFetch = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorTyped(err)
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)

View File

@@ -8,6 +8,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -29,7 +30,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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
return output.ErrValidation("%s", err)
}
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileToken: fileToken,

View File

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

View File

@@ -97,11 +97,13 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
}
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
// Factory.Config may be nil in tests that pass a zero-value 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.
var brand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
brand = cfg.Brand
if f != nil && f.ConfigBrand != nil {
if b, ok := f.ConfigBrand(); ok {
brand = b
}
}

View File

@@ -20,6 +20,10 @@ 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/errs"
"github.com/larksuite/cli/internal/output"
"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.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
data, err := runtime.CallAPI("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 "", errs.NewValidationError(errs.SubtypeFailedPrecondition, "no sheets found in this spreadsheet")
return "", output.Errorf(output.ExitAPI, "not_found", "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.ValidationErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
return common.FlagErrorf("--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.ValidationErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
return common.FlagErrorf("--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) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
return "", fmt.Errorf("invalid cell reference: %s", cell)
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1]) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
return "", fmt.Errorf("invalid column: %s", matches[1])
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -16,10 +15,10 @@ import (
func parseValues2DJSON(raw string) ([][]interface{}, error) {
var rows [][]interface{}
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
}
if rows == nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
}
return rows, nil
}
@@ -47,7 +46,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
@@ -91,7 +90,7 @@ var SheetRead = common.Shortcut{
params["valueRenderOption"] = renderOption
}
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
if err != nil {
return err
}
@@ -168,7 +167,7 @@ var SheetWrite = common.Shortcut{
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
}
data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPI("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,
@@ -248,7 +247,7 @@ var SheetAppend = common.Shortcut{
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
}
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPI("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,
@@ -289,7 +288,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
@@ -337,7 +336,7 @@ var SheetFind = common.Shortcut{
"find": findText,
}
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
data, err := runtime.CallAPI("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
}
@@ -374,7 +373,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
@@ -416,7 +415,7 @@ var SheetReplace = common.Shortcut{
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
data, err := runtime.CallAPITyped("POST",
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
return common.FlagErrorf("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
return output.ErrValidation("cannot read image file: %s", 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.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPI("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, errs.NewValidationError(errs.SubtypeInvalidArgument, "no file I/O provider registered")
return nil, output.ErrValidation("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, errs.NewValidationError(errs.SubtypeInvalidArgument, "image must be a regular file: %s", imagePath).WithParam("--image")
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024).WithParam("--image")
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
}
return stat, nil
}
func wrapSheetWriteImageStatError(err error, imagePath string) error {
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
return output.ErrValidation("unsafe image path: %s", err)
}
if os.IsNotExist(err) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "image file not found: %s", imagePath).WithParam("--image").WithCause(err)
return output.ErrValidation("image file not found: %s", imagePath)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot stat image file: %s", err).WithParam("--image").WithCause(err)
return output.ErrValidation("cannot stat image file: %s", err)
}
func wrapSheetWriteImageOpenError(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
return output.ErrValidation("unsafe image path: %s", err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
return output.ErrValidation("cannot read image file: %s", err)
}

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must not be empty").WithParam("--range")
return common.FlagErrorf("--range must not be empty")
}
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.CallAPITyped("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range or --filter-view-name")
return common.FlagErrorf("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.CallAPITyped("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
data, err := runtime.CallAPI("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.CallAPITyped("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
data, err := runtime.CallAPI("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.CallAPITyped("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
data, err := runtime.CallAPI("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.CallAPITyped("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
data, err := runtime.CallAPI("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.CallAPITyped("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --filter-type, --compare-type, or --expected")
return common.FlagErrorf("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.CallAPITyped("PUT",
data, err := runtime.CallAPI("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.CallAPITyped("GET",
data, err := runtime.CallAPI("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.CallAPITyped("GET",
data, err := runtime.CallAPI("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.CallAPITyped("DELETE",
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--expected must be a JSON array (e.g. [\"6\"]), got: %s", s).WithParam("--expected")
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
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.WrapInputStatErrorTyped(err, "file not found")
return "", nil, common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
}
return filePath, stat, nil
}
@@ -131,7 +131,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
}
}
if token == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -181,7 +181,7 @@ func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
}
}
if token == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
return "", common.FlagErrorf("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range prefix %q does not match --sheet-id %q", prefix, sheetID).WithParam("--range")
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
}
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
return common.FlagErrorf("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be >= 20 pixels, got %d", v).WithParam("--width")
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("height") {
if v := runtime.Int("height"); v < 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be >= 20 pixels, got %d", v).WithParam("--height")
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-x") {
if v := runtime.Int("offset-x"); v < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-x must be >= 0, got %d", v).WithParam("--offset-x")
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
}
}
if runtime.Cmd.Flags().Changed("offset-y") {
if v := runtime.Int("offset-y"); v < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-y must be >= 0, got %d", v).WithParam("--offset-y")
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
}
}
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.CallAPITyped("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
data, err := runtime.CallAPI("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.CallAPITyped("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
data, err := runtime.CallAPI("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.CallAPITyped("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
data, err := runtime.CallAPI("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.CallAPITyped("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
data, err := runtime.CallAPI("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.CallAPITyped("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}

View File

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

View File

@@ -76,23 +76,6 @@ 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,13 +6,14 @@ 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"
)
@@ -401,26 +402,38 @@ func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.
t.Fatal("expected move failure, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err)
}
if p.Code != 1310211 {
t.Fatalf("error code = %d, want 1310211", p.Code)
if exitErr.Detail.Code != 1310211 {
t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code)
}
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.Message, `sheet copied successfully as "sheet_copy"`) {
t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message)
}
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, "do not retry +copy-sheet") {
t.Fatalf("hint missing retry guard: %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)
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)
}
if p.LogID != "log-move-failed" {
t.Fatalf("log_id = %q, want %q", p.LogID, "log-move-failed")
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")
}
}

View File

@@ -5,10 +5,11 @@ package backward
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -20,63 +21,63 @@ func sheetBatchUpdatePath(token string) string {
}
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
if err := common.ExactlyOne(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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--spreadsheet-token").WithCause(err)
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
url := strings.TrimSpace(runtime.Str("url"))
if url == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/<token>").WithParam("--url")
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--url").WithCause(err)
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
func validateSheetID(flagName, sheetID string) error {
if strings.TrimSpace(sheetID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --%s", flagName).WithParam("--" + flagName)
return common.FlagErrorf("specify --%s", flagName)
}
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
return common.FlagErrorf("%v", err)
}
return nil
}
func validateSheetTitle(flagName, title string) error {
if title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not be empty", flagName).WithParam("--" + flagName)
return common.FlagErrorf("--%s must not be empty", flagName)
}
if strings.ContainsAny(title, "\t\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain tabs or line breaks", flagName).WithParam("--" + flagName)
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
}
if err := validate.RejectControlChars(title, flagName); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
return common.FlagErrorf("%v", err)
}
if len([]rune(title)) > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be <= 100 characters", flagName).WithParam("--" + flagName)
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
}
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain any of / \\ ? * [ ] :", flagName).WithParam("--" + flagName)
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
}
return nil
}
func validateNonNegativeInt(flagName string, value int) error {
if value < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be >= 0, got %d", flagName, value).WithParam("--" + flagName)
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
}
return nil
}
@@ -286,18 +287,36 @@ 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
@@ -310,22 +329,46 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
sheetID,
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
detail := map[string]interface{}{
"partial_success": true,
"failed_step": "move_copied_sheet",
"spreadsheet_token": token,
"sheet_id": sheetID,
"requested_index": index,
"retry_command": retryCommand,
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).WithHint(hint).WithCause(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,
}
}
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
@@ -354,7 +397,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--lock-info").WithCause(err)
return common.FlagErrorf("%v", err)
}
}
@@ -362,24 +405,24 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
if hasProtectConfig {
lock := runtime.Str("lock")
if !runtime.Changed("lock") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --lock when updating protection settings").WithParam("--lock")
return common.FlagErrorf("specify --lock when updating protection settings")
}
if runtime.Changed("lock-info") && lock != "LOCK" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--lock-info requires --lock LOCK").WithParam("--lock-info")
return common.FlagErrorf("--lock-info requires --lock LOCK")
}
if runtime.Changed("user-ids") {
if lock != "LOCK" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --lock LOCK").WithParam("--user-ids")
return common.FlagErrorf("--user-ids requires --lock LOCK")
}
if runtime.Str("user-id-type") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --user-id-type").WithParam("--user-id-type")
return common.FlagErrorf("--user-ids requires --user-id-type")
}
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
if err != nil {
return err
}
if len(userIDs) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids must not be empty").WithParam("--user-ids")
return common.FlagErrorf("--user-ids must not be empty")
}
}
}
@@ -391,7 +434,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
runtime.Changed("frozen-col-count") ||
hasProtectConfig
if !hasUpdate {
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 common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
}
return nil
@@ -487,7 +530,7 @@ var SheetCreateSheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
if err != nil {
return err
}
@@ -550,7 +593,7 @@ var SheetCopySheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
if err != nil {
return err
}
@@ -561,7 +604,7 @@ var SheetCopySheet = common.Shortcut{
}
if runtime.Changed("index") {
copiedSheetID, _ := out["sheet_id"].(string)
moveResp, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
if err != nil {
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
}
@@ -601,7 +644,7 @@ var SheetDeleteSheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
if err != nil {
return err
}
@@ -664,7 +707,7 @@ var SheetUpdateSheet = common.Shortcut{
params = map[string]interface{}{"user_id_type": userIDType}
}
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), params, body)
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
return nil
},
@@ -55,7 +55,7 @@ var SheetInfo = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
spreadsheetData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
spreadsheetData, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
}
}
if dataStr := runtime.Str("data"); dataStr != "" {
var rows [][]interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
}
}
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
}
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
}
if len(rows) > 0 {
allRows = append(allRows, rows...)
@@ -151,7 +151,7 @@ var SheetCreate = common.Shortcut{
createData["folder_token"] = folderToken
}
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
data, err := runtime.CallAPI("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.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
if _, err := runtime.CallAPI("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,11 +211,8 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--sheet-id is required when --file-extension is csv").WithParam("--sheet-id")
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
}
return nil
},
@@ -241,8 +238,10 @@ var SheetExport = common.Shortcut{
outputPath := runtime.Str("output-path")
sheetID := runtime.Str("sheet-id")
if err := validateSheetExportOutputPath(runtime); err != nil {
return err
if outputPath != "" {
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
}
exportData := map[string]interface{}{
@@ -254,7 +253,7 @@ var SheetExport = common.Shortcut{
exportData["sub_id"] = sheetID
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
if err != nil {
return err
}
@@ -281,7 +280,7 @@ var SheetExport = common.Shortcut{
}
if fileToken == "" {
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "export task timed out").WithRetryable()
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
}
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
@@ -299,7 +298,7 @@ var SheetExport = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return wrapSheetsNetworkErr(err, "download failed: %s", err)
return output.ErrNetwork("download failed: %s", err)
}
defer resp.Body.Close()
@@ -308,7 +307,7 @@ var SheetExport = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorTyped(err)
return common.WrapSaveErrorByCategory(err, "io")
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
@@ -322,14 +321,3 @@ 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

@@ -1,15 +0,0 @@
// 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.ValidationErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
return common.FlagErrorf("--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.ValidationErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
return nil, common.FlagErrorf("+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.ValidationErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
return nil, common.FlagErrorf("+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.ValidationErrorf("--source-index must be >= 0")
return nil, common.FlagErrorf("--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.ValidationErrorf("+sheet-move in +batch-update requires index")
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
}
if fv.Int("index") < 0 {
return nil, common.ValidationErrorf("--index must be >= 0")
return nil, common.FlagErrorf("--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.ValidationErrorf("operations[%d] must be a JSON object", index)
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
}
scRaw, present := op["shortcut"]
if !present {
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' field is required", index)
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
}
sc, ok := scRaw.(string)
if !ok || sc == "" {
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
}
mapping, ok := batchOpDispatch[sc]
if !ok {
return nil, common.ValidationErrorf(
return nil, common.FlagErrorf(
"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.ValidationErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
return nil, common.FlagErrorf("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.ValidationErrorf(
return nil, common.FlagErrorf(
"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.ValidationErrorf(
return nil, common.FlagErrorf(
"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.ValidationErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
return nil, common.FlagErrorf("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.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
return nil, common.FlagErrorf("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.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
return nil, common.FlagErrorf("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.ValidationErrorf("--operations must be a non-empty JSON array")
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {

View File

@@ -21,6 +21,7 @@ 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) {
@@ -37,21 +38,6 @@ 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,15 +453,16 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
p, ok := errs.ProblemOf(err)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
}
if !strings.Contains(p.Message, "shtNEW") {
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
if exitErr.Detail == nil {
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
}
if !strings.Contains(p.Hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
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"])
}
}

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.ValidationErrorf("--%s: %s", name, vErr.Error())
return common.FlagErrorf("--%s: %s", name, vErr.Error())
}
return nil
}

View File

@@ -12,40 +12,20 @@ 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.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
if err := common.ExactlyOne(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 "", sheetsValidationCauseForFlag("spreadsheet-token", err)
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
@@ -53,10 +33,10 @@ func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", sheetsValidationCauseForFlag("url", err)
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
@@ -84,18 +64,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.ExactlyOneTyped(runtime, "sheet-id", "sheet-name"); err != nil {
if err := common.ExactlyOne(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 "", "", sheetsValidationCauseForFlag("sheet-id", err)
return "", "", common.FlagErrorf("%v", err)
}
return id, "", nil
}
name := strings.TrimSpace(runtime.Str("sheet-name"))
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
return "", "", sheetsValidationCauseForFlag("sheet-name", err)
return "", "", common.FlagErrorf("%v", err)
}
return "", name, nil
}
@@ -136,26 +116,18 @@ func requireSheetSelector(sheetID, sheetName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID == "" && sheetName == "" {
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"),
)
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
}
if sheetID != "" && sheetName != "" {
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
return sheetsValidationCauseForFlag("sheet-id", err)
return common.FlagErrorf("%v", err)
}
} else {
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
return sheetsValidationCauseForFlag("sheet-name", err)
return common.FlagErrorf("%v", err)
}
}
return nil
@@ -180,19 +152,15 @@ func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string)
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID != "" && sheetName != "" {
return common.ValidationErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName).
WithParams(
sheetsInvalidParam(idFlagName, "mutually exclusive"),
sheetsInvalidParam(nameFlagName, "mutually exclusive"),
)
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
return sheetsValidationCauseForFlag(idFlagName, err)
return common.FlagErrorf("%v", err)
}
} else if sheetName != "" {
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
return sheetsValidationCauseForFlag(nameFlagName, err)
return common.FlagErrorf("%v", err)
}
}
return nil
@@ -229,7 +197,7 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
@@ -248,11 +216,11 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
return nil, err
}
if v == nil {
return nil, sheetsValidationForFlag(name, "--%s is required", name)
return nil, common.FlagErrorf("--%s is required", name)
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, sheetsValidationForFlag(name, "--%s must be a JSON object", name)
return nil, common.FlagErrorf("--%s must be a JSON object", name)
}
return m, nil
}
@@ -264,11 +232,11 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
return nil, err
}
if v == nil {
return nil, sheetsValidationForFlag(name, "--%s is required", name)
return nil, common.FlagErrorf("--%s is required", name)
}
a, ok := v.([]interface{})
if !ok {
return nil, sheetsValidationForFlag(name, "--%s must be a JSON array", name)
return nil, common.FlagErrorf("--%s must be a JSON array", name)
}
return a, nil
}
@@ -325,7 +293,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
return nil, common.FlagErrorf("--border-styles must be a JSON object")
}
return m, nil
}
@@ -339,10 +307,5 @@ func requireAnyStyleFlag(runtime flagView) error {
if runtime.Str("border-styles") != "" {
return nil
}
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"),
)
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
}

View File

@@ -6,13 +6,11 @@ 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"
@@ -81,71 +79,6 @@ 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.ValidationErrorf("--operations is required")
return nil, common.FlagErrorf("--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.ValidationErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
return nil, common.FlagErrorf("--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.ValidationErrorf("range %q: %v", rng, err)
return nil, common.FlagErrorf("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.ValidationErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
return common.FlagErrorf("--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.ValidationErrorf("range %q: %v", rng, err)
return nil, common.FlagErrorf("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.ValidationErrorf("--ranges[%d] must be a string", i)
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
}
s = strings.TrimSpace(s)
if !strings.Contains(s, "!") {
return nil, common.ValidationErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
return nil, common.FlagErrorf("--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.ValidationErrorf("--ranges[%d]: %v", i, err)
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
}
if _, _, err := rangeDimensions(sub); err != nil {
return nil, common.ValidationErrorf("--ranges[%d] (%q): %v", i, s, err)
return nil, common.FlagErrorf("--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.ValidationErrorf("range %q must use sheet!range form", rng)
return "", "", common.FlagErrorf("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/errs"
"github.com/larksuite/cli/internal/output"
"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.ValidationErrorf("--%s is required", spec.idFlag)
return nil, common.FlagErrorf("--%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.ValidationErrorf("--%s is required", spec.idFlag)
return nil, common.FlagErrorf("--%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.ValidationErrorf("+sparkline-update properties.sparklines must be an array")
return common.FlagErrorf("+sparkline-update properties.sparklines must be an array")
}
for i, item := range arr {
m, _ := item.(map[string]interface{})
if m == nil {
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
}
id, _ := m["sparkline_id"].(string)
if strings.TrimSpace(id) == "" {
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 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 nil
@@ -595,20 +595,20 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
}
}
if set == 0 && requireImageSource {
return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required")
return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required")
}
if set > 1 {
return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive")
return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive")
}
name := floatImageName(runtime)
if name == "" {
return nil, common.ValidationErrorf("--image-name is required")
return nil, common.FlagErrorf("--image-name is required")
}
if !runtime.Changed("position-row") || !runtime.Changed("position-col") {
return nil, common.ValidationErrorf("--position-row and --position-col are required")
return nil, common.FlagErrorf("--position-row and --position-col are required")
}
if !runtime.Changed("size-width") || !runtime.Changed("size-height") {
return nil, common.ValidationErrorf("--size-width and --size-height are required")
return nil, common.FlagErrorf("--size-width and --size-height are required")
}
props := map[string]interface{}{
"image_name": name,
@@ -626,9 +626,7 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
return nil, output.ErrValidation("%s", err)
}
if uploadedImageToken != "" {
props["image_token"] = uploadedImageToken
@@ -748,7 +746,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
}
info, err := runtime.FileIO().Stat(img)
if err != nil {
return "", common.WrapInputStatErrorTyped(err)
return "", common.WrapInputStatError(err)
}
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: img,
@@ -764,7 +762,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.ValidationErrorf("--float-image-id is required")
return nil, common.FlagErrorf("--float-image-id is required")
}
props, err := floatImageProperties(runtime, uploadedImageToken, op == "create")
if err != nil {
@@ -884,7 +882,7 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
props := map[string]interface{}{
"range": strings.TrimSpace(runtime.Str("range")),
@@ -959,10 +957,10 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if sheetID == "" {
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)")
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)")
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
@@ -1033,7 +1031,7 @@ func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if sheetID == "" {
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)")
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)")
}
input := map[string]interface{}{
"excel_id": token,

View File

@@ -5,9 +5,10 @@ package sheets
import (
"context"
"errors"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -75,7 +76,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -107,22 +108,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 {
p, ok := errs.ProblemOf(err)
if !ok {
var ee *output.ExitError
if !errors.As(err, &ee) || ee.Detail == nil {
return err
}
if !strings.Contains(strings.ToLower(p.Message), "embedded block") {
if !strings.Contains(strings.ToLower(ee.Detail.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 p.Hint == "" {
p.Hint = hint
if ee.Detail.Hint == "" {
ee.Detail.Hint = hint
} else {
p.Hint += "; " + hint
ee.Detail.Hint += "; " + hint
}
return err
return ee
}
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
@@ -190,7 +191,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -344,36 +345,36 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
return nil, err
}
if !runtime.Changed("range") {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
return nil, common.FlagErrorf("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.ValidationErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
return nil, common.FlagErrorf("--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.ValidationErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
}
if dimension == "column" && typ == "auto" {
return nil, common.ValidationErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
return nil, common.FlagErrorf("--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.ValidationErrorf("--type pixel requires --size <px>")
return nil, common.FlagErrorf("--type pixel requires --size <px>")
}
if typ != "pixel" && hasSize {
return nil, common.ValidationErrorf("--size is only valid with --type pixel")
return nil, common.FlagErrorf("--size is only valid with --type pixel")
}
input := map[string]interface{}{
"excel_id": token,
@@ -566,10 +567,10 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.ValidationErrorf("--source-range is required")
return nil, common.FlagErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.ValidationErrorf("--target-range is required")
return nil, common.FlagErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -608,10 +609,10 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.ValidationErrorf("--source-range is required")
return nil, common.FlagErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.ValidationErrorf("--target-range is required")
return nil, common.FlagErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -640,7 +641,7 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--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/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -16,35 +16,34 @@ func TestAnnotateEmbeddedBlockClearErr(t *testing.T) {
t.Parallel()
t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) {
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")
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")
}
if !strings.Contains(p.Hint, "+pivot-delete") {
t.Errorf("hint should point at +pivot-delete, got %q", p.Hint)
if !strings.Contains(ee.Detail.Hint, "+pivot-delete") {
t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint)
}
})
t.Run("appends to existing hint", func(t *testing.T) {
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)
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)
}
})
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)
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)
}
})

View File

@@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.ValidationErrorf("--range is required")
return common.FlagErrorf("--range is required")
}
return nil
},
@@ -142,7 +142,7 @@ var CsvGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.ValidationErrorf("--range is required")
return common.FlagErrorf("--range is required")
}
return nil
},
@@ -484,7 +484,7 @@ var DropdownGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.ValidationErrorf("--range is required")
return common.FlagErrorf("--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.ValidationErrorf("--find is required")
return common.FlagErrorf("--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.ValidationErrorf("--find is required")
return nil, common.FlagErrorf("--find is required")
}
if !runtime.Changed("replacement") {
return nil, common.ValidationErrorf("--replacement is required (pass an empty string to delete matches)")
return nil, common.FlagErrorf("--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.ValidationErrorf("--position is required")
return nil, common.FlagErrorf("--position is required")
}
if !runtime.Changed("count") {
return nil, common.ValidationErrorf("--count is required")
return nil, common.FlagErrorf("--count is required")
}
position := strings.TrimSpace(runtime.Str("position"))
if _, _, err := parseA1Position(position); err != nil {
return nil, common.ValidationErrorf("invalid --position %q: %v", position, err)
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
}
count := runtime.Int("count")
if count <= 0 {
return nil, common.ValidationErrorf("--count must be > 0 (got %d)", count)
return nil, common.FlagErrorf("--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.ValidationErrorf("--dimension is required")
return nil, common.FlagErrorf("--dimension is required")
}
if !runtime.Changed("count") {
return nil, common.ValidationErrorf("--count is required (0 unfreezes)")
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, common.ValidationErrorf("--count must be >= 0")
return nil, common.FlagErrorf("--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.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if _, _, _, err := parseA1Range(rangeStr); err != nil {
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
return nil, common.FlagErrorf("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.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
data, err := runtime.CallAPI("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.ValidationErrorf("--source-range and --target are required")
return nil, common.FlagErrorf("--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.ValidationErrorf("invalid --source-range %q: %v", src, err)
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
}
tgt := strings.TrimSpace(runtime.Str("target"))
tgtDim, tgtIdx, err := parseA1Position(tgt)
if err != nil {
return nil, common.ValidationErrorf("invalid --target %q: %v", tgt, err)
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
}
if tgtDim != dim {
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
return nil, common.FlagErrorf("--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.ValidationErrorf("--title is required")
return nil, common.FlagErrorf("--title is required")
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
return nil, common.FlagErrorf("--row-count must be between 0 and 50000")
}
if n := runtime.Int("col-count"); n < 0 || n > 200 {
return nil, common.ValidationErrorf("--col-count must be between 0 and 200")
return nil, common.FlagErrorf("--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.ValidationErrorf("--title is required")
return nil, common.FlagErrorf("--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.ValidationErrorf("--color is required (empty string clears)")
return nil, common.FlagErrorf("--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.ValidationErrorf("--index is required")
return common.FlagErrorf("--index is required")
}
if runtime.Int("index") < 0 {
return common.ValidationErrorf("--index must be >= 0")
return common.FlagErrorf("--index must be >= 0")
}
if runtime.Changed("source-index") && runtime.Int("source-index") < 0 {
return common.ValidationErrorf("--source-index must be >= 0")
return common.FlagErrorf("--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.ValidationErrorf("--title is required")
return common.FlagErrorf("--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.ValidationErrorf("--headers must be a JSON array")
return common.FlagErrorf("--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.ValidationErrorf("--values must be a JSON 2D array")
return common.FlagErrorf("--values must be a JSON 2D array")
}
for i, r := range rows {
if _, ok := r.([]interface{}); !ok {
return common.ValidationErrorf("--values[%d] must be an array", i)
return common.FlagErrorf("--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.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
data, err := runtime.CallAPI("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 errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response")
return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response")
}
result := map[string]interface{}{"spreadsheet": ss}
@@ -665,9 +665,19 @@ 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, _ 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")
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,
},
},
}
}
// buildInitialFillInput zips --headers + --values into a single set_cell_range
@@ -755,7 +765,7 @@ var WorkbookExport = common.Shortcut{
ext = "xlsx"
}
if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
return common.ValidationErrorf("--sheet-id is required when --file-extension=csv")
return common.FlagErrorf("--sheet-id is required when --file-extension=csv")
}
return nil
},
@@ -803,13 +813,13 @@ var WorkbookExport = common.Shortcut{
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
taskData, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return err
}
ticket := common.GetString(taskData, "ticket")
if ticket == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket missing")
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
}
result := map[string]interface{}{
@@ -837,9 +847,9 @@ var WorkbookExport = common.Shortcut{
continue
default: // any non-zero status outside the in-progress window is a failure
if 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: %s", ticket, status.JobErrorMsg)
}
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed with job_status=%d", ticket, status.JobStatus)
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
}
}
if fileToken == "" {
@@ -877,7 +887,7 @@ type exportTaskStatus struct {
}
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
data, err := runtime.CallAPITyped(
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
@@ -888,7 +898,7 @@ func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (expor
}
result := common.GetMap(data, "result")
if result == nil {
return exportTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "export task %s: empty result", ticket)
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
}
js, _ := util.ToFloat64(result["job_status"])
fs, _ := util.ToFloat64(result["file_size"])
@@ -908,10 +918,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 "", sheetsDownloadRequestError(err)
return "", output.ErrNetwork("download failed: %s", err)
}
if apiResp.StatusCode >= 400 {
return "", sheetsDownloadHTTPStatusError(apiResp)
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
}
target := outPath
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
@@ -925,7 +935,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.WrapSaveErrorTyped(err)
return "", common.WrapSaveErrorByCategory(err, "io")
}
resolved, _ := runtime.FileIO().ResolvePath(target)
if resolved == "" {
@@ -934,57 +944,6 @@ 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.
@@ -997,7 +956,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
}
m, ok := out.(map[string]interface{})
if !ok {
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
}
sheets, _ := m["sheets"].([]interface{})
for _, raw := range sheets {
@@ -1016,7 +975,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, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field")
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field")
}
return id, int(idx), nil
}
@@ -1025,7 +984,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
if target == "" {
target = sheetName
}
return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target)
return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target))
}
// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the
@@ -1042,7 +1001,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
m, ok := out.(map[string]interface{})
if !ok {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
}
sheets, _ := m["sheets"].([]interface{})
bestID := ""
@@ -1070,7 +1029,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
}
if bestID == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets")
}
return bestID, nil
}

View File

@@ -4,14 +4,9 @@
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"
)
@@ -396,92 +391,6 @@ func TestWorkbookExport_DryRun(t *testing.T) {
})
}
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
t.Parallel()
t.Run("preserves typed request errors", func(t *testing.T) {
t.Parallel()
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
got := sheetsDownloadRequestError(in)
if got != in {
t.Fatalf("typed error was not preserved: got %T %v", got, got)
}
})
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
t.Parallel()
got := sheetsDownloadRequestError(errors.New("dial refused"))
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T %v", got, got)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
})
tests := []struct {
name string
status int
wantCategory errs.Category
wantSubtype errs.Subtype
wantRetryable bool
}{
{
name: "5xx is retryable network server error",
status: http.StatusBadGateway,
wantCategory: errs.CategoryNetwork,
wantSubtype: errs.SubtypeNetworkServer,
wantRetryable: true,
},
{
name: "404 is API not found",
status: http.StatusNotFound,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeNotFound,
},
{
name: "429 is retryable API rate limit",
status: http.StatusTooManyRequests,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeRateLimit,
wantRetryable: true,
},
{
name: "other 4xx is API unknown",
status: http.StatusForbidden,
wantCategory: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
StatusCode: tt.status,
RawBody: []byte("body"),
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
})
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T %v", got, got)
}
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
}
if p.Code != tt.status {
t.Fatalf("code = %d, want %d", p.Code, tt.status)
}
if p.LogID != "log123" {
t.Fatalf("log_id = %q, want log123", p.LogID)
}
if p.Retryable != tt.wantRetryable {
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
}
})
}
}
// assertInputEquals compares the decoded tool input map against the wanted
// fields. Extra fields in `got` are allowed (defaults, optional fields);
// every key in `want` must match exactly.

View File

@@ -15,7 +15,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"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.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--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.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
@@ -218,7 +218,6 @@ 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 {
@@ -301,10 +300,7 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
return nil, err
}
if strings.TrimSpace(runtime.Str("csv")) == "" {
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")
return nil, common.FlagErrorf("--csv is required")
}
anchor := strings.TrimSpace(runtime.Str("start-cell"))
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
@@ -315,24 +311,23 @@ 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 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.
// 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.
if !runtime.Changed("start-cell") {
rng := strings.TrimSpace(runtime.Str("range"))
if rng == "" {
return nil, common.ValidationErrorf("--start-cell or --range is required")
return nil, common.FlagErrorf("--start-cell or --range is required")
}
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
}
if anchor == "" {
return nil, common.ValidationErrorf("--start-cell is required")
return nil, common.FlagErrorf("--start-cell is required")
}
if _, _, ok := splitCellRef(anchor); !ok {
return nil, common.ValidationErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
}
input := map[string]interface{}{
"excel_id": token,
@@ -403,11 +398,11 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.ValidationErrorf("--range is required")
return nil, common.FlagErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
@@ -466,7 +461,7 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
return nil, err
}
if len(colors) > sourceSize {
return nil, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
dv["highlight_colors"] = colors
}
@@ -488,9 +483,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.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one")
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
case optsRaw == "" && sourceRange == "":
return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
case optsRaw != "":
options, err := requireJSONArray(runtime, "options")
if err != nil {
@@ -503,7 +498,7 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
default: // sourceRange != ""
rows, cols, err := rangeDimensions(sourceRange)
if err != nil {
return 0, nil, common.ValidationErrorf("--source-range %q: %v", sourceRange, err)
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
}
return rows * cols, map[string]interface{}{
"type": "listFromRange",
@@ -528,7 +523,7 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
return 0, err
}
if len(colors) > sourceSize {
return 0, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
}
return sourceSize, nil
@@ -701,18 +696,18 @@ var CellsSetImage = common.Shortcut{
}
r := strings.TrimSpace(runtime.Str("range"))
if r == "" {
return common.ValidationErrorf("--range is required")
return common.FlagErrorf("--range is required")
}
rows, cols, err := rangeDimensions(r)
if err != nil {
return common.ValidationErrorf("--range %q: %v", r, err)
return common.FlagErrorf("--range %q: %v", r, err)
}
if rows != 1 || cols != 1 {
return common.ValidationErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
}
imgPath := strings.TrimSpace(runtime.Str("image"))
if imgPath == "" {
return common.ValidationErrorf("--image is required")
return common.FlagErrorf("--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.
@@ -720,9 +715,7 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
return output.ErrValidation("%s", err)
}
return nil
},
@@ -778,18 +771,16 @@ var CellsSetImage = common.Shortcut{
}
info, err := runtime.FileIO().Stat(imgPath)
if err != nil {
return common.WrapInputStatErrorTyped(err)
return common.WrapInputStatError(err)
}
imgFile, err := runtime.FileIO().Open(imgPath)
if err != nil {
return common.WrapInputStatErrorTyped(err)
return common.WrapInputStatError(err)
}
imgCfg, _, err := image.DecodeConfig(imgFile)
imgFile.Close()
if err != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "decode image dimensions: %s", err).
WithParam("--image").
WithCause(err)
return fmt.Errorf("decode image dimensions: %w", err)
}
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: imgPath,
@@ -818,7 +809,7 @@ var CellsSetImage = common.Shortcut{
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
if err != nil {
return wrapCellsSetImageWriteError(err, fileToken)
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
@@ -831,18 +822,3 @@ 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/errs"
"github.com/larksuite/cli/internal/output"
"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, errs.NewInternalError(errs.SubtypeSDKError, "encode tool input: %v", err).WithCause(err)
return nil, fmt.Errorf("encode tool input: %w", err)
}
return map[string]interface{}{
"tool_name": toolName,
@@ -77,14 +77,13 @@ func callTool(
envelope, ok := raw.(map[string]interface{})
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
return nil, output.Errorf(output.ExitAPI, "tool_response",
"tool %q: unexpected non-JSON-object response: %v", toolName, raw)
}
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
WithCode(int(code))
return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"])
}
data, _ := envelope["data"].(map[string]interface{})
rawOutput, _ := data["output"].(string)
@@ -94,8 +93,8 @@ func callTool(
var out interface{}
if err := json.Unmarshal([]byte(rawOutput), &out); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"tool %q returned invalid JSON output: %v", toolName, err).WithCause(err)
return nil, output.Errorf(output.ExitAPI, "tool_output",
"tool %q returned invalid JSON output: %v", toolName, err)
}
return out, nil
}

View File

@@ -9,7 +9,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--presentation cannot be empty").WithParam("--presentation")
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
}
// 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{}, 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{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
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{}, 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{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
// 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{}, 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{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
}
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.CallAPITyped(
data, err := runtime.CallAPI(
"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 "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
}
if objType != "slides" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slides shortcuts require a slides presentation", objType).WithParam("--presentation")
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
}
return objToken, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported presentation ref kind %q", ref.Kind)
return "", output.ErrValidation("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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "no root element found in XML fragment")
return "", fmt.Errorf("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/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -33,9 +33,6 @@ 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"},
@@ -45,24 +42,24 @@ var SlidesCreate = common.Shortcut{
if slidesStr := runtime.Str("slides"); slidesStr != "" {
var slides []string
if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides invalid JSON, must be an array of XML strings").WithParam("--slides")
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
}
if len(slides) > 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")
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
}
// 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 slidesInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides @%s: must be a regular file", path).WithParam("--slides")
return common.FlagErrorf("--slides @%s: must be a regular file", path)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
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")
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
path, common.FormatSize(stat.Size()))
}
}
}
@@ -128,7 +125,7 @@ var SlidesCreate = common.Shortcut{
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPITyped(
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
@@ -144,7 +141,7 @@ var SlidesCreate = common.Shortcut{
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
@@ -168,7 +165,9 @@ var SlidesCreate = common.Shortcut{
if len(placeholders) > 0 {
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("presentation %s was created; %d image(s) uploaded before failure", presentationID, uploaded))
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
}
for i := range slides {
slides[i] = replaceImagePlaceholders(slides[i], tokens)
@@ -183,7 +182,7 @@ var SlidesCreate = common.Shortcut{
var slideIDs []string
for i, slideXML := range slides {
slideData, err := runtime.CallAPITyped(
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
@@ -192,7 +191,9 @@ var SlidesCreate = common.Shortcut{
},
)
if err != nil {
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))
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)
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
@@ -204,14 +205,29 @@ var SlidesCreate = common.Shortcut{
}
}
// 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
// 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
}
}
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
@@ -252,10 +268,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, slidesInputStatError(err, fmt.Sprintf("@%s: file not found", path))
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
}
if !stat.Mode().IsRegular() {
return tokens, i, errs.NewValidationError(errs.SubtypeInvalidArgument, "@%s: must be a regular file", path).WithParam("--slides")
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
}
fileName := filepath.Base(path)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
@@ -263,7 +279,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) //nolint:forbidigo // intermediate; preserves typed cause via %w, reclassified by appendSlidesProgressHint at the call site
return tokens, i, fmt.Errorf("@%s: %w", path, err)
}
tokens[path] = token
}

View File

@@ -12,7 +12,6 @@ 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"
@@ -36,6 +35,7 @@ func TestSlidesCreateBasic(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -53,10 +53,8 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
// 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 data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -80,6 +78,7 @@ 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",
@@ -132,6 +131,7 @@ 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,6 +168,7 @@ func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -237,6 +238,7 @@ func TestSlidesCreateDefaultTitle(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -299,6 +301,7 @@ 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",
@@ -401,21 +404,15 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %v", err)
errMsg := err.Error()
if !strings.Contains(errMsg, "pres_partial") {
t.Fatalf("error should contain presentation ID, 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, "slide 2/2") {
t.Fatalf("error should indicate slide 2/2 failed, 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)
if !strings.Contains(errMsg, "1 slide(s) added") {
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
}
}
@@ -481,6 +478,7 @@ 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",
@@ -553,6 +551,7 @@ 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",
@@ -581,12 +580,8 @@ func TestSlidesCreateWithoutSlidesUnchanged(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) {
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -597,15 +592,24 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_local_url",
"xml_presentation_id": "pres_no_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", "Local URL",
"--title", "No URL",
"--as", "user",
})
if err != nil {
@@ -613,11 +617,11 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_local_url" {
t.Fatalf("xml_presentation_id = %v, want pres_local_url", data["xml_presentation_id"])
if data["xml_presentation_id"] != "pres_no_url" {
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
}
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"])
if _, ok := data["url"]; ok {
t.Fatalf("did not expect url when batch_query fails")
}
}
@@ -668,6 +672,22 @@ 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()
@@ -738,6 +758,7 @@ 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

@@ -1,48 +0,0 @@
// 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/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -86,15 +86,15 @@ var SlidesMediaUpload = common.Shortcut{
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return slidesInputStatError(err, "file not found")
return common.WrapInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
return output.ErrValidation("file must be a regular file: %s", filePath)
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
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")
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size()))
}
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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
return "", output.ErrValidation("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