Compare commits

..

13 Commits

Author SHA1 Message Date
liangshuo-1
7fdf55821b chore(release): v1.0.50 (#1359) 2026-06-09 22:43:44 +08:00
evandance
201e3e016f feat(doc): emit typed error envelopes across the doc domain (#1346)
Emit structured validation, API, network, file, and internal error envelopes for Doc shortcuts so users and agents can recover from failed document workflows using stable type, subtype, param, and code fields.

Add Doc domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-09 20:43:20 +08:00
xiongyuanwen-byted
eed711bb11 feat(sheets): guard +csv-put --csv against a path passed without @ (#1337)
+csv-put --csv data.csv (a forgotten @) was silently written as one-cell content, because any string parses as valid CSV — unlike malformed JSON it never errored, so the filename landed in the sheet instead of the file's contents.

+csv-put's Validate now rejects a --csv value when it names a real file in the cwd subtree (guardCSVValueIsNotFilePath; fileIO.Stat, fail-open), hinting to use --csv @file or stdin (--csv -). Scoped to --csv only — no framework or other-flag change. Checking real existence (not name shape) lets inline content that merely ends in a filename pass through. Adds TestGuardCSVValueIsNotFilePath.
2026-06-09 19:48:28 +08:00
fangshuyu-768
4f4c0b59c9 docs(lark-doc): replace append with block_insert_after in skeleton workflow guidance (#1340)
`append` always inserts at document end (equiv. `block_insert_after --block-id -1`),
but skill docs previously recommended it for the "skeleton + chapter-by-chapter fill"
pattern, causing all content to pile up after the last heading.

Changes:
- Remove `append` from skeleton workflow guidance in `lark-doc-create-workflow.md`
  and `lark-doc-create.md`; recommend `block_insert_after` with explicit `--block-id`
- Fix `block_move_after` required params: remove `--content` (not supported),
  only `--block-id` and `--src-block-ids` are valid
- Add bash language tag to code block for proper highlighting
2026-06-09 18:11:56 +08:00
evandance
2b4c6349a1 feat(event): emit typed error envelopes across the event domain (#1289)
Replace every command-facing error path in the event domain — the
consume/schema command layer, the +subscribe shortcut, EventKey
definitions, and the consume orchestration — with typed errs.*
envelopes, so consumers get stable type, subtype, param, hint, and
missing_scopes metadata for classification and recovery instead of
free-form message text.

- Input validation (--jq, --param, --output-dir, --filter, --route,
  unknown EventKey, EventKey params) reports validation /
  invalid_argument with the offending flag in param and an actionable
  hint.
- Scope preflight reports authorization / missing_scope with the
  machine-readable missing_scopes list; console-subscription and
  single-bus preconditions report failed_precondition with recovery
  hints.
- The consume API boundary passes already-typed errors through and
  classifies transport, non-JSON HTTP, and unparsable responses; the
  vc note-detail retry now matches the not-found code on typed errors
  (it silently never fired against the legacy envelope shape).
- Previously-bare failures exited 1 with a plain-text "Error:" line
  and now exit with their category code (validation 2, auth 3,
  network 4, internal 5) alongside the typed stderr envelope.
- forbidigo and errscontract guards now cover the event paths so
  regressions fail lint; AGENTS.md and the lark-event skill document
  the typed contract for agent consumers.

Validation: make unit-test (race) green; event unit and e2e suites
assert category/subtype/param/hint and cause preservation against the
real binary; errscontract and golangci lint clean.
2026-06-09 17:12:55 +08:00
wangweiming-01
944cd55fc7 docs: add drive comment location guidance (#1258)
Change-Id: I7cfdfd5a456658cca89fc974ef7a85dc20c2c395
2026-06-09 17:00:56 +08:00
fangshuyu-768
7229baae40 fix: clarify --block-id supports comma-separated batch delete in help text (#1336) 2026-06-09 15:21:09 +08:00
fangshuyu-768
170565c57e fix: add @file/stdin support to drive +add-comment --content (#1343) 2026-06-09 15:20:25 +08:00
evandance
03ea6e78b8 feat(contact): emit typed error envelopes across the contact domain (#1287) 2026-06-09 12:07:35 +08:00
ViperCai
ed3fe9337f fix(slides): build create URL locally instead of drive metas call (#1329)
slides +create finished by calling /drive/v1/metas/batch_query just to
fetch the presentation URL. That call needs a drive scope the shortcut
never declares, so it 403'd for users who only authorized slides scopes
(both UserAccessToken re-auth and TenantAccessToken scope-not-opened),
producing a large share of the shortcut's failure telemetry — even though
the presentation itself was already created successfully.

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

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

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

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
### stdout is data, stderr is everything else

View File

@@ -2,6 +2,68 @@
All notable changes to this project will be documented in this file.
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
@@ -1026,6 +1088,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return err
return errs.NewInternalError(errs.SubtypeFileIO,
"set up bus logger: %s", err).WithCause(err)
}
tr := transport.New()
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
}()
return b.Run(ctx)
if err := b.Run(ctx); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
},
}

45
cmd/event/bus_test.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return output.ErrWithHint(
output.ExitValidation, "validation",
err.Error(),
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--jq").
WithCause(err).
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
}
}
@@ -261,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
@@ -301,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(
output.ExitValidation, "validation",
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")),
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID))
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") {
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
}
safe, err := validate.SafeOutputPath(dir)
if err != nil {
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
}
return safe, nil
}
@@ -329,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
}
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil {
return "", output.ErrAuth("resolve tenant access token: %s", err)
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
}
if result == nil || result.Token == "" {
return "", output.ErrWithHint(
output.ExitAuth, "auth",
fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no tenant access token available for app %s", appID).
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
}
return result.Token, nil
}
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
@@ -352,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" {
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
}
m[k] = v
}

View File

@@ -4,9 +4,14 @@
package event
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
)
func TestParseParams(t *testing.T) {
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
}
assertInvalidArgumentParam(t, err, "--param")
return
}
if err != nil {
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
}
}
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) {
cases := []struct {
name string
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
}
assertInvalidArgumentParam(t, err, "--output-dir")
return
}
if err != nil {

View File

@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
)
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if exit.Code != output.ExitValidation {
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err)
}
var exit *output.ExitError
if !errors.As(err, &exit) {
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if exit.Code != output.ExitAuth {
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
errs.CategoryAuthorization, errs.SubtypeMissingScope)
}
if exit.Detail == nil {
t.Fatal("expected Detail with hint, got nil Detail")
wantMissing := []string{"im:message.group_at_msg"}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
}
hint := exit.Detail.Hint
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",

View File

@@ -6,8 +6,8 @@ package event
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
As: r.accessIdentity,
})
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
}
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type")
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)"
}
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
if resp.StatusCode >= 500 {
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
}
result, err := client.ParseJSONResponse(resp)
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
}
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr

147
cmd/event/runtime_test.go Normal file
View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, err
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"parse base schema for field overrides: %s", err).WithCause(err)
}
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed)
if err != nil {
return nil, nil, err
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
"serialize schema with field overrides: %s", err).WithCause(err)
}
return out, orphans, nil
}
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
copy(buf, s.Raw)
return buf, nil
}
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
}
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
resolved, _, err := resolveSchemaJSON(def)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
return err
}
if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n")

View File

@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
@@ -129,3 +130,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
t.Errorf("overlay format = %v, want open_id", got)
}
}
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -8,8 +8,8 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?"
}
return output.ErrWithHint(
output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithHint("Run 'lark-cli event list' to see available keys.")
}

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ package minutes
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// isLarkCode must match the API code on typed errs.* errors — the consume
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
// Problem.Code rather than the legacy envelope shape.
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
WithCode(vcNoteDetailNotFoundCode)
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
t.Fatal("typed API error carrying the not-found code must match (retry path)")
}
if isLarkCode(typedNotFound, 99999) {
t.Error("a different expected code must not match")
}
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
t.Error("typed error with another code must not match")
}
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
t.Error("untyped error must not match")
}
}

View File

@@ -6,12 +6,11 @@ package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
)
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
}
func isLarkCode(err error, code int) bool {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
if p, ok := errs.ProblemOf(err); ok {
return p.Code == code
}
return false
}

View File

@@ -5,9 +5,9 @@ package vc
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
body := map[string]string{"event_type": eventType}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
@@ -24,11 +25,15 @@ const cleanupTimeout = 5 * time.Second
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
whiteboardID := params["whiteboard_id"]
if whiteboardID == "" {
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"param whiteboard_id is required for %s", eventType).
WithParam("--param").
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
}
encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)

View File

@@ -11,6 +11,7 @@ import (
"sync"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
@@ -58,6 +59,16 @@ func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("missing whiteboard_id should carry a hint")
}
}
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
@@ -70,6 +81,9 @@ func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
if err == nil {
t.Fatalf("expected error when runtime client is nil")
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/transport"
)
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
keyDef, ok := event.Lookup(opts.EventKey)
if !ok {
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown EventKey: %s", opts.EventKey).
WithHint("run `lark-cli event list` to see available keys")
}
if err := validateParams(keyDef, opts.Params); err != nil {
@@ -80,7 +83,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
if err != nil {
return fmt.Errorf("handshake failed: %w", err)
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
}
var cleanup func()
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
if err != nil {
return fmt.Errorf("pre-consume failed: %w", err)
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"pre-consume failed: %s", err).WithCause(err)
}
}
@@ -152,8 +160,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
for _, p := range def.Params {
if p.Required {
if _, ok := params[p.Name]; !ok {
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
p.Name, def.Key, def.Key)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"required param %q missing for EventKey %s", p.Name, def.Key).
WithParam("--param").
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
}
}
}
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
continue
}
if len(validNames) == 0 {
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
k, def.Key, def.Key)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown param %q: EventKey %s accepts no params", k, def.Key).
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
}
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
k, def.Key, strings.Join(validNames, ", "), def.Key)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
}
return nil
}

View File

@@ -8,17 +8,21 @@ import (
"fmt"
"github.com/itchyny/gojq"
"github.com/larksuite/cli/errs"
)
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
func CompileJQ(expr string) (*gojq.Code, error) {
query, err := gojq.Parse(expr)
if err != nil {
return nil, fmt.Errorf("invalid jq expression: %w", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
}
code, err := gojq.Compile(query)
if err != nil {
return nil, fmt.Errorf("jq compile error: %w", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
}
return code, nil
}

View File

@@ -5,10 +5,13 @@ package consume
import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCompileJQReportsErrorEarly(t *testing.T) {
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
}
if errors.Unwrap(err) == nil {
t.Error("compile error should preserve its cause")
}
}
func TestCompileJQReturnsUsableCode(t *testing.T) {

View File

@@ -13,6 +13,7 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/vfs"
)
@@ -23,7 +24,8 @@ type Sink interface {
func newSink(opts Options) (Sink, error) {
if opts.OutputDir != "" {
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"create output dir: %s", err).WithCause(err)
}
// PID disambiguates filenames across processes sharing a Dir.
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil

View File

@@ -16,6 +16,7 @@ import (
"path/filepath"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
} else {
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
if count > 0 {
return nil, fmt.Errorf("another event bus is already connected to this app "+
"(%d active connection(s) detected via API).\n"+
"Only one bus should run globally to avoid duplicate event delivery.\n"+
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
}
}
} else {
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
pid, forkErr := forkBus(tr, appID, profileName, domain)
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"failed to start event bus daemon: %s", forkErr).
WithCause(forkErr).
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
}
if pid > 0 {
announceForkedBus(errOut, pid)
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
}
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
// failDialTransport refuses every dial so EnsureBus falls through to the
// remote-connection check without a local bus.
type failDialTransport struct{}
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
func (failDialTransport) Address(string) string { return "guard-test-addr" }
func (failDialTransport) Cleanup(string) {}
// remoteBusyAPIClient reports active remote WebSocket connections.
type remoteBusyAPIClient struct{ count int }
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
strconv.Itoa(c.count) + `}}`), nil
}
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
conn, err := EnsureBus(context.Background(), failDialTransport{},
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
if conn != nil {
t.Fatal("expected nil conn when a remote bus is already connected")
}
if err == nil {
t.Fatal("expected single-bus guard error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Hint, "event stop") {
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
}
}
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "bogus.run.key",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected unknown EventKey error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "event list") {
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
}
}
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
event.RegisterKey(event.KeyDefinition{
Key: "consume.runtest.jq",
EventType: "consume.runtest.jq_v1",
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
})
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "consume.runtest.jq",
JQExpr: "[invalid{{{",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected jq validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--jq" {
t.Errorf("param = %q, want %q", ve.Param, "--jq")
}
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
func requireParamValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("param validation error should hint at `lark-cli event schema`")
}
}
func TestValidateParams_RequiredMissing(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
}
requireParamValidationError(t, validateParams(def, map[string]string{}))
}
func TestValidateParams_UnknownParam(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id"}},
}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
def := &event.KeyDefinition{Key: "x.test"}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
}
params := map[string]string{}
if err := validateParams(def, params); err != nil {
t.Fatalf("default should satisfy required param, got: %v", err)
}
if params["mode"] != "all" {
t.Errorf("default not applied, params=%v", params)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -147,6 +147,22 @@ func saveCacheMeta(meta CacheMeta) error {
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
}
func loadCachedMerged() (*MergedRegistry, error) {
path := cachePath()
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
var reg MergedRegistry
if err := json.Unmarshal(data, &reg); err != nil {
// Cache corrupted — remove it so next run triggers a fresh fetch
vfs.Remove(path)
vfs.Remove(cacheMetaPath())
return nil, err
}
return &reg, nil
}
func saveCachedMerged(data []byte, meta CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
@@ -237,7 +253,7 @@ func doSyncFetch() {
Brand: string(configuredBrand),
}
_ = saveCachedMerged(data, meta)
_ = loadCachedTyped()
overlayMergedServices(reg)
}
// --- background refresh ---
@@ -292,3 +308,15 @@ func shouldRefresh(meta CacheMeta) bool {
}
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
}
// overlayMergedServices merges remote services into the in-memory map.
// Remote entries override embedded entries with the same name.
func overlayMergedServices(reg *MergedRegistry) {
for _, svc := range reg.Services {
name, ok := svc["name"].(string)
if !ok || name == "" {
continue
}
mergedServices[name] = svc
}
}

View File

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

View File

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

View File

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

View File

@@ -7,12 +7,10 @@ import (
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/registry/metaschema"
)
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
@@ -37,6 +35,58 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
// We only assert key-set membership, not absolute order — the upstream
// meta_data API does not guarantee a stable JSON key sequence across
// fetches, so hard-coding the order makes CI flaky. Order preservation
// from input to output is tested separately in TestBuildInputSchema_*.
order := lookupKeyOrder("im", []string{"reactions"}, "list")
if order == nil {
t.Fatal("expected key order for im.reactions.list, got nil")
}
wantParams := map[string]bool{
"message_id": true, "reaction_type": true, "page_token": true,
"page_size": true, "user_id_type": true,
}
if got, want := len(order.Parameters), len(wantParams); got != want {
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
}
for _, k := range order.Parameters {
if !wantParams[k] {
t.Errorf("unexpected parameter key %q", k)
}
}
// im.reactions.list 是 GET没有 requestBody
if len(order.RequestBody) != 0 {
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
}
}
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
order := lookupKeyOrder("im", []string{"images"}, "create")
if order == nil {
t.Fatal("expected key order for im.images.create, got nil")
}
wantBody := map[string]bool{"image_type": true, "image": true}
if got, want := len(order.RequestBody), len(wantBody); got != want {
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
}
for _, k := range order.RequestBody {
if !wantBody[k] {
t.Errorf("unexpected requestBody key %q", k)
}
}
}
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
// 远端缓存的命令(不在 embedded 内)查不到 key order返回 nil 走字母序兜底
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
if order != nil {
t.Errorf("expected nil for unknown path, got %+v", order)
}
}
func TestConvertProperty_BasicTypes(t *testing.T) {
tests := []struct {
name string
@@ -238,6 +288,9 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
func TestBuildInputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -260,15 +313,16 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
// tool spec) where object property order carries no meaning.
if !sort.StringsAreSorted(params.Properties.Order) {
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
}
}
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -328,6 +382,9 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
},
},
}
currentMethodOrder = nil
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// yes lives at inputSchema.properties.yes (sibling of params/data)
@@ -356,6 +413,9 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if _, ok := is.Properties.Map["yes"]; ok {
@@ -365,6 +425,9 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
os := buildOutputSchema(method)
@@ -550,45 +613,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// TestBuildMeta_AffordanceThroughTypedRegistry guards the static-registry path:
// a method's affordance must survive metaschema.Method -> registry.MethodToMap
// -> buildMeta, so `schema --format json` keeps emitting _meta.affordance after
// the embedded-JSON-to-typed-registry migration. Without typed-side support the
// overlay is silently stripped whenever meta_data.json carries affordance.
func TestBuildMeta_AffordanceThroughTypedRegistry(t *testing.T) {
mth := metaschema.Method{
Name: "primary",
Affordance: &metaschema.Affordance{
UseWhen: []string{"用户想拿到自己默认日历的 ID"},
DoNotUseWhen: []string{"已经知道某个具体日历的 ID"},
Prerequisites: []string{"user 身份登录"},
Examples: []metaschema.AffordanceExample{
{Description: "取主日历", Command: "lark-cli calendar calendars primary"},
},
Related: []string{"calendars.list", "calendars.get"},
},
}
method := registry.MethodToMap(mth)
m := buildMeta(method)
if m.Affordance == nil {
t.Fatal("affordance dropped through the typed registry (MethodToMap -> buildMeta)")
}
a := m.Affordance
if len(a.UseWhen) != 1 || a.UseWhen[0] != "用户想拿到自己默认日历的 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.DoNotUseWhen) != 1 || len(a.Prerequisites) != 1 {
t.Errorf("DoNotUseWhen=%v Prerequisites=%v", a.DoNotUseWhen, a.Prerequisites)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 2 {
t.Errorf("Related = %v", a.Related)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
@@ -610,6 +634,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
// 装配器对空 responseBody 应生成 properties = {} (不 nil
method := map[string]interface{}{}
currentMethodOrder = nil
os := buildOutputSchema(method)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)

View File

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

View File

@@ -15,9 +15,15 @@ import (
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",

View File

@@ -16,9 +16,15 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/event/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",

View File

@@ -27,6 +27,11 @@ import (
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
// they return the raw response for the caller to classify and do not emit a
// legacy envelope themselves.
//
// Files that do not import shortcuts/common are skipped: the legacy helpers
// are methods on common.RuntimeContext, so a same-named method on another
// receiver (for example the event domain's APIClient interface, whose
// implementation classifies into typed errs.* errors) is not a legacy call.
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if err != nil {
return nil
}
if !importsPath(file, commonImportPath) {
return nil
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
}
return "", false
}
// importsPath reports whether the file imports the given package path.
func importsPath(file *ast.File, importPath string) bool {
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
if strings.Trim(imp.Path.Value, "`\"") == importPath {
return true
}
}
return false
}

View File

@@ -691,7 +691,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -813,6 +813,8 @@ func boom() error {
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
@@ -833,6 +835,8 @@ func boom(runtime *common.RuntimeContext) error {
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
src := `package task
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
@@ -853,6 +857,8 @@ func boom(runtime *common.RuntimeContext) error {
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
return err
@@ -907,7 +913,7 @@ func boom(runtime *common.RuntimeContext) error {
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
@@ -944,6 +950,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"HandleApiResult",
}
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
"shortcuts/okr/okr_progress_create.go",
@@ -997,6 +1004,23 @@ func boom() {
}
}
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact
@@ -1006,7 +1030,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}
@@ -1076,3 +1100,23 @@ func boom() error {
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
// The event domain's APIClient interface has a same-named CallAPI method
// whose implementation classifies into typed errs.* errors; without the
// shortcuts/common import the call cannot be the legacy RuntimeContext
// helper and must not fire.
src := `package vc
import "github.com/larksuite/cli/internal/event"
func boom(rt event.APIClient) error {
_, err := rt.CallAPI(nil, "POST", "/x", nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
if len(v) != 0 {
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// 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)
}
// wrapDocInputFileErr wraps a --file Stat/read failure via the shared typed
// helper (which sets the cause) and tags it with the --file param so agents
// learn which flag to fix. The common helper is flag-agnostic, so the param is
// attached here at the Doc call site rather than mutating shared behavior.
func wrapDocInputFileErr(err error, readMsg string) error {
wrapped := common.WrapInputStatErrorTyped(err, readMsg)
var ve *errs.ValidationError
if errors.As(wrapped, &ve) {
ve.Param = "--file"
}
return wrapped
}

View File

@@ -0,0 +1,420 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"errors"
"slices"
"strconv"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// testDocxToken is a bare docx token that parseDocumentRef accepts, letting the
// validation tests reach the flag checks that run after --doc is resolved.
const testDocxToken = "doxcnDocErrorsTestToken"
// docValidateRuntime builds a RuntimeContext carrying only the flags a Doc
// Validate function reads. String values are applied (and marked Changed) only
// when non-empty; int values are always applied so Changed() reports true,
// mirroring how cobra records an explicitly supplied numeric flag.
func docValidateRuntime(t *testing.T, str map[string]string, bools map[string]bool, ints map[string]int) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "docs"}
fs := cmd.Flags()
for name, val := range str {
fs.String(name, "", "")
if val != "" {
if err := fs.Set(name, val); err != nil {
t.Fatalf("set --%s=%q: %v", name, val, err)
}
}
}
for name, val := range bools {
fs.Bool(name, false, "")
if val {
if err := fs.Set(name, "true"); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
}
for name, val := range ints {
fs.Int(name, 0, "")
if err := fs.Set(name, strconv.Itoa(val)); err != nil {
t.Fatalf("set --%s=%d: %v", name, val, err)
}
}
return common.TestNewRuntimeContext(cmd, nil)
}
// assertValidationContract pins the typed envelope every migrated Doc
// validation fault must emit: a *errs.ValidationError in CategoryValidation
// with the expected Subtype, the single offending flag in Param, and every
// involved flag in Params. Single-flag faults set Param and leave Params empty;
// multi-flag faults (mutual exclusion, "one of A or B") leave Param empty and
// enumerate each flag in Params so agents resolve them without parsing the text.
func assertValidationContract(t *testing.T, err error, wantSubtype errs.Subtype, wantParam string, wantParams ...string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err)
}
if ve.Category != errs.CategoryValidation {
t.Errorf("category = %q, want %q", ve.Category, errs.CategoryValidation)
}
if ve.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q", ve.Subtype, wantSubtype)
}
if ve.Param != wantParam {
t.Errorf("param = %q, want %q", ve.Param, wantParam)
}
gotParams := make([]string, len(ve.Params))
for i, p := range ve.Params {
gotParams[i] = p.Name
}
if !slices.Equal(gotParams, wantParams) {
t.Errorf("params = %v, want %v", gotParams, wantParams)
}
}
func TestDocMediaInsertValidateContract(t *testing.T) {
cases := []struct {
name string
str map[string]string
bools map[string]bool
ints map[string]int
wantParam string
wantParams []string
}{
{
name: "neither file nor clipboard",
str: map[string]string{"doc": testDocxToken},
wantParam: "", // one-of-two flags: enumerated in Params
wantParams: []string{"--file", "--from-clipboard"},
},
{
name: "file and clipboard together",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png"},
bools: map[string]bool{"from-clipboard": true},
wantParam: "", // mutual exclusion: enumerated in Params
wantParams: []string{"--file", "--from-clipboard"},
},
{
name: "non-docx document",
str: map[string]string{"doc": "https://example.larksuite.com/doc/xxxxxx", "file": "dummy.png"},
wantParam: "--doc",
},
{
name: "blank selection",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "selection-with-ellipsis": " "},
wantParam: "--selection-with-ellipsis",
},
{
name: "before without selection",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png"},
bools: map[string]bool{"before": true},
wantParam: "--before",
},
{
name: "invalid file-view",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "bogus"},
wantParam: "--file-view",
},
{
name: "file-view without type file",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "card", "type": "image"},
wantParam: "--file-view",
},
{
name: "dimensions with non-image type",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "file"},
ints: map[string]int{"width": 100},
wantParam: "", // only --width was set here, so only it is enumerated
wantParams: []string{"--width"},
},
{
name: "non-positive width",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
ints: map[string]int{"width": 0},
wantParam: "--width",
},
{
name: "non-positive height",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
ints: map[string]int{"height": 0},
wantParam: "--height",
},
{
name: "width over maximum",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
ints: map[string]int{"width": 10001},
wantParam: "--width",
},
{
name: "height over maximum",
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
ints: map[string]int{"height": 10001},
wantParam: "--height",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := docValidateRuntime(t, tc.str, tc.bools, tc.ints)
err := DocMediaInsert.Validate(context.Background(), rt)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
})
}
}
func TestValidateCreateV2Contract(t *testing.T) {
cases := []struct {
name string
str map[string]string
wantParam string
wantParams []string
}{
{
name: "content required",
str: map[string]string{},
wantParam: "--content",
},
{
name: "parent token and position mutually exclusive",
str: map[string]string{"content": "<doc/>", "parent-token": "fldcnX", "parent-position": "my_library"},
wantParam: "", // mutual exclusion: enumerated in Params
wantParams: []string{"--parent-token", "--parent-position"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := docValidateRuntime(t, tc.str, nil, nil)
err := validateCreateV2(context.Background(), rt)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
})
}
}
func TestValidateFetchV2Contract(t *testing.T) {
cases := []struct {
name string
str map[string]string
ints map[string]int
wantParam string
wantParams []string
}{
{
name: "range mode without block ids",
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "range"},
wantParam: "", // either --start-block-id or --end-block-id: enumerated in Params
wantParams: []string{"--start-block-id", "--end-block-id"},
},
{
name: "keyword mode without keyword",
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "keyword"},
wantParam: "--keyword",
},
{
name: "section mode without start block id",
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "section"},
wantParam: "--start-block-id",
},
{
name: "negative context-before",
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "outline"},
ints: map[string]int{"context-before": -1},
wantParam: "--context-before",
},
{
name: "unknown scope",
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "bogus"},
wantParam: "--scope",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := docValidateRuntime(t, tc.str, nil, tc.ints)
err := validateFetchV2(context.Background(), rt)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
})
}
}
// TestBuildDocsSearchRequestPreservesParseCause pins the --filter parse faults:
// the typed envelope carries Param --filter and chains the original parse error
// so errors.Is/Unwrap traversal keeps the underlying JSON/time-parse detail.
func TestBuildDocsSearchRequestPreservesParseCause(t *testing.T) {
cases := []struct {
name string
filter string
}{
{"invalid filter json", "{not json"},
{"invalid open_time start", `{"open_time":{"start":"not-a-time"}}`},
{"invalid open_time end", `{"open_time":{"end":"not-a-time"}}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := buildDocsSearchRequest("q", tc.filter, "", "15")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--filter" {
t.Errorf("param = %q, want %q", ve.Param, "--filter")
}
if errors.Unwrap(ve) == nil {
t.Error("parse error not chained: errors.Unwrap == nil")
}
})
}
}
// TestWrapDocNetworkErr pins wrapDocNetworkErr's contract: a typed error passes
// through untouched, while a raw error becomes a transport-level NetworkError
// that still chains the original cause for errors.Is/Unwrap.
func TestWrapDocNetworkErr(t *testing.T) {
t.Run("typed error passes through unchanged", func(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")
got := wrapDocNetworkErr(typed, "fetch failed")
if got != error(typed) {
t.Fatalf("typed error must pass through unchanged, got %T", got)
}
})
t.Run("raw error becomes transport network error", func(t *testing.T) {
raw := errors.New("dial tcp: i/o timeout")
got := wrapDocNetworkErr(raw, "fetch failed: %s", "docx")
var ne *errs.NetworkError
if !errors.As(got, &ne) {
t.Fatalf("raw error must become *errs.NetworkError, got %T", got)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(got, raw) {
t.Error("cause not chained: errors.Is(got, raw) == false")
}
})
}
// TestWrapDocInputFileErr pins that a --file stat/read failure becomes a typed
// validation error tagged with the --file param and the cause preserved, so an
// agent knows which flag to fix even though the shared helper is flag-agnostic.
func TestWrapDocInputFileErr(t *testing.T) {
raw := errors.New("no such file or directory")
got := wrapDocInputFileErr(raw, "file not found")
var ve *errs.ValidationError
if !errors.As(got, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", got, got)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--file" {
t.Errorf("param = %q, want %q", ve.Param, "--file")
}
if !errors.Is(got, raw) {
t.Error("cause not chained: errors.Is(got, raw) == false")
}
}
func TestValidateUpdateV2Contract(t *testing.T) {
cases := []struct {
name string
str map[string]string
wantParam string
}{
{
name: "command required",
str: map[string]string{"doc": testDocxToken},
wantParam: "--command",
},
{
name: "invalid command",
str: map[string]string{"doc": testDocxToken, "command": "bogus"},
wantParam: "--command",
},
{
name: "str_replace without pattern",
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
wantParam: "--block-id",
},
{
name: "block_insert_after without content",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after", "block-id": "blkX"},
wantParam: "--content",
},
{
name: "block_copy_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after"},
wantParam: "--block-id",
},
{
name: "block_copy_insert_after without src block ids",
str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after", "block-id": "blkX"},
wantParam: "--src-block-ids",
},
{
name: "block_move_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_move_after"},
wantParam: "--block-id",
},
{
name: "block_move_after without src block ids",
str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX"},
wantParam: "--src-block-ids",
},
{
name: "block_move_after rejects content",
str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX", "src-block-ids": "blkY", "content": "x"},
wantParam: "--content",
},
{
name: "block_replace without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_replace"},
wantParam: "--block-id",
},
{
name: "block_replace without content",
str: map[string]string{"doc": testDocxToken, "command": "block_replace", "block-id": "blkX"},
wantParam: "--content",
},
{
name: "overwrite without content",
str: map[string]string{"doc": testDocxToken, "command": "overwrite"},
wantParam: "--content",
},
{
name: "append without content",
str: map[string]string{"doc": testDocxToken, "command": "append"},
wantParam: "--content",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := docValidateRuntime(t, tc.str, nil, nil)
err := validateUpdateV2(context.Background(), rt)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam)
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -24,11 +25,11 @@ var validCommandsV2 = map[string]bool{
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
@@ -43,14 +44,14 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return common.FlagErrorf("invalid --doc: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
cmd := runtime.Str("command")
if cmd == "" {
return common.FlagErrorf("--command is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command is required").WithParam("--command")
}
if !validCommandsV2[cmd] {
return common.FlagErrorf("invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
}
content := runtime.Str("content")
pattern := runtime.Str("pattern")
@@ -60,50 +61,50 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
switch cmd {
case "str_replace":
if pattern == "" {
return common.FlagErrorf("--command str_replace requires --pattern")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return common.FlagErrorf("--command block_delete requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
case "block_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_insert_after requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
}
if content == "" {
return common.FlagErrorf("--command block_insert_after requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --content").WithParam("--content")
}
case "block_copy_insert_after":
if blockID == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --block-id").WithParam("--block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --src-block-ids").WithParam("--src-block-ids")
}
case "block_move_after":
if blockID == "" {
return common.FlagErrorf("--command block_move_after requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --block-id").WithParam("--block-id")
}
if srcBlockIDs == "" {
return common.FlagErrorf("--command block_move_after requires --src-block-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --src-block-ids").WithParam("--src-block-ids")
}
if content != "" {
return common.FlagErrorf("--command block_move_after does not accept --content; use --src-block-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after does not accept --content; use --src-block-ids").WithParam("--content")
}
case "block_replace":
if blockID == "" {
return common.FlagErrorf("--command block_replace requires --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --block-id").WithParam("--block-id")
}
if content == "" {
return common.FlagErrorf("--command block_replace requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --content").WithParam("--content")
}
case "overwrite":
if content == "" {
return common.FlagErrorf("--command overwrite requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command overwrite requires --content").WithParam("--content")
}
case "append":
if content == "" {
return common.FlagErrorf("--command append requires --content")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
return nil

View File

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

View File

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

View File

@@ -133,7 +133,7 @@ var DriveAddComment = common.Shortcut{
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true},
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},

32
shortcuts/event/errors.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import "github.com/larksuite/cli/errs"
func eventValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func eventValidationParamError(param, format string, args ...any) *errs.ValidationError {
return eventValidationError(format, args...).WithParam(param)
}
// eventValidationParamErrorWithCause appends ": <err>" to the formatted
// message and preserves err as the unwrap cause.
func eventValidationParamErrorWithCause(err error, param, format string, args ...any) *errs.ValidationError {
return eventValidationParamError(param, format+": %s", append(args, err)...).WithCause(err)
}
// eventFileIOError appends ": <err>" to the formatted message and preserves
// err as the unwrap cause.
func eventFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format+": %s", append(args, err)...).WithCause(err)
}
// eventNetworkError appends ": <err>" to the formatted message and preserves
// err as the unwrap cause.
func eventNetworkError(err error, format string, args ...any) *errs.NetworkError {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format+": %s", append(args, err)...).WithCause(err)
}

View File

@@ -63,13 +63,13 @@ func NewEventPipeline(
func (p *EventPipeline) EnsureDirs() error {
if p.config.OutputDir != "" {
if err := vfs.MkdirAll(p.config.OutputDir, 0700); err != nil {
return fmt.Errorf("create output dir: %w", err)
return eventFileIOError(err, "create output dir")
}
}
if p.config.Router != nil {
for _, route := range p.config.Router.routes {
if err := vfs.MkdirAll(route.dir, 0700); err != nil {
return fmt.Errorf("create route dir %s: %w", route.dir, err)
return eventFileIOError(err, "create route dir %s", route.dir)
}
}
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
@@ -15,7 +16,13 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile"
"github.com/larksuite/cli/shortcuts/common"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/spf13/cobra"
)
// chdirTemp changes cwd to a fresh temp dir for the test duration.
@@ -44,6 +51,87 @@ func makeRawEvent(eventType string, eventJSON string) *RawEvent {
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, param string) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) = false, error: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
if param != "" {
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error %T is not *errs.ValidationError", err)
}
if ve.Param != param {
t.Fatalf("Param = %q, want %q", ve.Param, param)
}
}
}
func TestEventTypedErrorHelpers(t *testing.T) {
cause := errors.New("cause")
validation := eventValidationError("bad input")
requireProblem(t, validation, errs.CategoryValidation, errs.SubtypeInvalidArgument, "")
paramErr := eventValidationParamErrorWithCause(cause, "--flag", "bad %s value", "flag")
requireProblem(t, paramErr, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--flag")
if got := paramErr.Error(); got != "bad flag value: cause" {
t.Fatalf("message = %q, want %q", got, "bad flag value: cause")
}
if !errors.Is(paramErr, cause) {
t.Fatal("validation error should preserve its cause")
}
fileErr := eventFileIOError(cause, "write failed")
requireProblem(t, fileErr, errs.CategoryInternal, errs.SubtypeFileIO, "")
if got := fileErr.Error(); got != "write failed: cause" {
t.Fatalf("message = %q, want %q", got, "write failed: cause")
}
if !errors.Is(fileErr, cause) {
t.Fatal("file_io error should preserve its cause")
}
networkErr := eventNetworkError(cause, "websocket failed")
requireProblem(t, networkErr, errs.CategoryNetwork, errs.SubtypeNetworkTransport, "")
if got := networkErr.Error(); got != "websocket failed: cause" {
t.Fatalf("message = %q, want %q", got, "websocket failed: cause")
}
if !errors.Is(networkErr, cause) {
t.Fatal("network error should preserve its cause")
}
}
func newSubscribeTestRuntime(t *testing.T) *common.RuntimeContext {
t.Helper()
var out, errOut bytes.Buffer
cmd := &cobra.Command{Use: "+subscribe"}
cmd.Flags().String("event-types", "", "")
cmd.Flags().String("filter", "", "")
cmd.Flags().Bool("json", false, "")
cmd.Flags().Bool("compact", false, "")
cmd.Flags().String("output-dir", "", "")
cmd.Flags().Bool("quiet", false, "")
cmd.Flags().StringArray("route", nil, "")
cmd.Flags().Bool("force", false, "")
return &common.RuntimeContext{
Cmd: cmd,
Config: &core.CliConfig{
AppID: "cli_event_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
},
Factory: &cmdutil.Factory{
IOStreams: cmdutil.NewIOStreams(strings.NewReader(""), &out, &errOut),
},
}
}
// --- Registry ---
func TestRegistryLookup(t *testing.T) {
@@ -63,9 +151,11 @@ func TestRegistryDuplicateReturnsError(t *testing.T) {
if err := r.Register(&ImMessageProcessor{}); err != nil {
t.Fatalf("first register should succeed: %v", err)
}
if err := r.Register(&ImMessageProcessor{}); err == nil {
err := r.Register(&ImMessageProcessor{})
if err == nil {
t.Error("expected error on duplicate registration")
}
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeUnknown, "")
}
// --- Filters ---
@@ -106,6 +196,54 @@ func TestRegexFilter_Invalid(t *testing.T) {
}
}
func TestEventSubscribeExecuteRejectsUnsafeOutputDir(t *testing.T) {
rt := newSubscribeTestRuntime(t)
if err := rt.Cmd.Flags().Set("output-dir", "/tmp/events"); err != nil {
t.Fatal(err)
}
err := EventSubscribe.Execute(context.Background(), rt)
if err == nil {
t.Fatal("expected unsafe output-dir error")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--output-dir")
if errors.Unwrap(err) == nil {
t.Fatal("unsafe output-dir error should preserve its cause")
}
}
func TestEventSubscribeExecuteRejectsInvalidFilter(t *testing.T) {
rt := newSubscribeTestRuntime(t)
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
t.Fatal(err)
}
if err := rt.Cmd.Flags().Set("filter", "[invalid"); err != nil {
t.Fatal(err)
}
err := EventSubscribe.Execute(context.Background(), rt)
if err == nil {
t.Fatal("expected invalid filter error")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--filter")
if errors.Unwrap(err) == nil {
t.Fatal("invalid filter error should preserve its cause")
}
}
func TestEventSubscribeExecuteRejectsInvalidRoute(t *testing.T) {
rt := newSubscribeTestRuntime(t)
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
t.Fatal(err)
}
if err := rt.Cmd.Flags().Set("route", "no-equals-sign"); err != nil {
t.Fatal(err)
}
err := EventSubscribe.Execute(context.Background(), rt)
if err == nil {
t.Fatal("expected invalid route error")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
}
func TestFilterChain(t *testing.T) {
etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1")
rf, _ := NewRegexFilter("im\\..*")
@@ -339,6 +477,106 @@ func TestPipeline_OutputDir(t *testing.T) {
}
}
func TestEventSubscribeExecuteRejectsHeldLock(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
lock, err := lockfile.ForSubscribe("cli_event_test")
if err != nil {
t.Fatal(err)
}
if err := lock.TryLock(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = lock.Unlock() })
rt := newSubscribeTestRuntime(t)
execErr := EventSubscribe.Execute(context.Background(), rt)
if execErr == nil {
t.Fatal("expected lock-held error")
}
requireProblem(t, execErr, errs.CategoryValidation, errs.SubtypeFailedPrecondition, "")
if !errors.Is(execErr, lockfile.ErrHeld) {
t.Error("lock-held error should preserve lockfile.ErrHeld for errors.Is")
}
p, _ := errs.ProblemOf(execErr)
if p.Hint == "" {
t.Error("lock-held error should carry a recovery hint")
}
var ve *errs.ValidationError
if errors.As(execErr, &ve) && ve.Param != "" {
t.Errorf("lock contention names no offending flag; param = %q, want empty", ve.Param)
}
}
func TestEventSubscribeDryRunEchoesFlags(t *testing.T) {
rt := newSubscribeTestRuntime(t)
for flag, value := range map[string]string{
"event-types": "im.message.receive_v1",
"filter": "^im\\.",
"output-dir": "events_out",
} {
if err := rt.Cmd.Flags().Set(flag, value); err != nil {
t.Fatal(err)
}
}
if err := rt.Cmd.Flags().Set("route", "^im\\.message=dir:./messages"); err != nil {
t.Fatal(err)
}
d := EventSubscribe.DryRun(context.Background(), rt)
if d == nil {
t.Fatal("DryRun returned nil")
}
payload, err := json.Marshal(d)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`"command":"event +subscribe"`,
`"app_id":"cli_event_test"`,
`"event_types":"im.message.receive_v1"`,
`"output_dir":"events_out"`,
} {
if !strings.Contains(string(payload), want) {
t.Errorf("dry-run payload missing %s\ngot: %s", want, payload)
}
}
}
func TestPipeline_EnsureDirsRouteDirFileIOError(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("blocked", []byte("x"), 0600); err != nil {
t.Fatal(err)
}
router, err := ParseRoutes([]string{`^im\.=dir:./blocked/child`})
if err != nil {
t.Fatalf("ParseRoutes: %v", err)
}
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
PipelineConfig{Mode: TransformCompact, Router: router}, io.Discard, io.Discard)
err = p.EnsureDirs()
if err == nil {
t.Fatal("expected file_io error for route dir blocked by a file")
}
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
}
func TestPipeline_EnsureDirsFileIOError(t *testing.T) {
path := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
t.Fatal(err)
}
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
PipelineConfig{Mode: TransformCompact, OutputDir: filepath.Join(path, "child")}, io.Discard, io.Discard)
err := p.EnsureDirs()
if err == nil {
t.Fatal("expected file_io error")
}
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
if errors.Unwrap(err) == nil {
t.Fatal("file_io error should preserve its cause")
}
}
// --- Pipeline: JsonFlag ---
func TestPipeline_JsonFlag(t *testing.T) {
@@ -608,6 +846,7 @@ func TestParseRoutes_MissingEquals(t *testing.T) {
if err == nil {
t.Error("expected error for missing =")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
}
func TestParseRoutes_InvalidRegex(t *testing.T) {
@@ -615,6 +854,10 @@ func TestParseRoutes_InvalidRegex(t *testing.T) {
if err == nil {
t.Error("expected error for invalid regex")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
if errors.Unwrap(err) == nil {
t.Fatal("invalid regex error should preserve its cause")
}
}
func TestParseRoutes_MissingPrefix(t *testing.T) {
@@ -622,6 +865,7 @@ func TestParseRoutes_MissingPrefix(t *testing.T) {
if err == nil {
t.Error("expected error for missing dir: prefix")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
if !strings.Contains(err.Error(), "dir:") {
t.Errorf("error should mention dir: prefix, got: %v", err)
}
@@ -632,6 +876,7 @@ func TestParseRoutes_EmptyPath(t *testing.T) {
if err == nil {
t.Error("expected error for empty path")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
}
func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
@@ -639,6 +884,7 @@ func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
if err == nil {
t.Error("expected error for absolute path in route")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
}
func TestParseRoutes_RejectsTraversal(t *testing.T) {
@@ -646,6 +892,7 @@ func TestParseRoutes_RejectsTraversal(t *testing.T) {
if err == nil {
t.Error("expected error for path traversal in route")
}
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
}
func TestParseRoutes_PathSafety(t *testing.T) {

View File

@@ -3,7 +3,7 @@
package event
import "fmt"
import "github.com/larksuite/cli/errs"
// ProcessorRegistry manages event_type → EventProcessor mappings.
type ProcessorRegistry struct {
@@ -23,7 +23,7 @@ func NewProcessorRegistry(fallback EventProcessor) *ProcessorRegistry {
func (r *ProcessorRegistry) Register(p EventProcessor) error {
et := p.EventType()
if _, exists := r.processors[et]; exists {
return fmt.Errorf("duplicate event processor for: %s", et)
return errs.NewInternalError(errs.SubtypeUnknown, "duplicate event processor for: %s", et)
}
r.processors[et] = p
return nil

View File

@@ -4,7 +4,6 @@
package event
import (
"fmt"
"regexp"
"strings"
@@ -34,27 +33,27 @@ func ParseRoutes(specs []string) (*EventRouter, error) {
for _, spec := range specs {
parts := strings.SplitN(spec, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec)
return nil, eventValidationParamError("--route", "invalid --route %q: expected format regex=dir:./path", spec)
}
pattern := parts[0]
target := parts[1]
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regex in route %q: %w", spec, err)
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid regex in --route %q", spec)
}
if !strings.HasPrefix(target, "dir:") {
return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
return nil, eventValidationParamError("--route", "invalid --route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
}
dir := strings.TrimPrefix(target, "dir:")
if dir == "" {
return nil, fmt.Errorf("invalid route %q: directory path is empty", spec)
return nil, eventValidationParamError("--route", "invalid --route %q: directory path is empty", spec)
}
safeDir, err := validate.SafeOutputPath(dir)
if err != nil {
return nil, fmt.Errorf("invalid route %q: %w", spec, err)
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid --route %q", spec)
}
routes = append(routes, Route{pattern: re, dir: safeDir})

View File

@@ -6,6 +6,7 @@ package event
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -13,6 +14,7 @@ import (
"strings"
"syscall"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile"
"github.com/larksuite/cli/internal/output"
@@ -144,7 +146,7 @@ var EventSubscribe = common.Shortcut{
if outputDir != "" {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return eventValidationParamErrorWithCause(err, "--output-dir", "unsafe --output-dir")
}
outputDir = safePath
}
@@ -162,15 +164,18 @@ var EventSubscribe = common.Shortcut{
if !forceFlag {
lock, err := lockfile.ForSubscribe(runtime.Config.AppID)
if err != nil {
return fmt.Errorf("failed to create lock: %w", err)
return eventFileIOError(err, "failed to create event subscriber lock")
}
if err := lock.TryLock(); err != nil {
return output.ErrValidation(
"another event +subscribe instance is already running for app %s\n"+
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
" Use --force to bypass this check.",
runtime.Config.AppID,
)
if errors.Is(err, lockfile.ErrHeld) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event +subscribe instance is already running for app %s\n"+
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
" Use --force to bypass this check.",
runtime.Config.AppID,
).WithHint("stop the existing subscriber for this app, or rerun with --force if you accept split event delivery").WithCause(err)
}
return eventFileIOError(err, "failed to acquire event subscriber lock")
}
defer lock.Unlock()
}
@@ -179,7 +184,7 @@ var EventSubscribe = common.Shortcut{
eventTypeFilter := NewEventTypeFilter(eventTypesStr)
regexFilter, err := NewRegexFilter(filterStr)
if err != nil {
return output.ErrValidation("invalid --filter regex: %s", filterStr)
return eventValidationParamErrorWithCause(err, "--filter", "invalid --filter regex %q", filterStr)
}
var filterList []EventFilter
if eventTypeFilter != nil {
@@ -193,7 +198,7 @@ var EventSubscribe = common.Shortcut{
// --- Parse route ---
router, err := ParseRoutes(routeSpecs)
if err != nil {
return output.ErrValidation("invalid --route: %v", err)
return err
}
// --- Build pipeline ---
@@ -292,7 +297,7 @@ var EventSubscribe = common.Shortcut{
return nil
}
if err != nil {
return output.ErrNetwork("WebSocket connection failed: %v", err)
return eventNetworkError(err, "WebSocket connection failed")
}
return nil
}

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("csv", "", "")
cmd.ParseFlags(nil)
cmd.Flags().Set("csv", csvVal)
return &common.RuntimeContext{Cmd: cmd}
}
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
// only when it names a real file (a forgotten @), while leaving genuine inline
// content alone — including the case the old name-shape heuristic got wrong:
// prose that merely ends in or mentions a filename.
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
t.Fatal(err)
}
// Bare value naming an existing file → guarded with a fix-it hint.
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
if err == nil {
t.Fatal("expected guard error when --csv names an existing file")
}
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
}
// Content that is not a real file must pass through unchanged.
for _, v := range []string{
"改完记得更新config.json", // prose ending in a filename — not a real file
"remember to update data.csv", // mentions the real file but isn't its name
"a,b\n1,2", // multi-cell CSV
"hello world",
"nope.csv", // path-shaped but no such file
"",
} {
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
t.Errorf("content %q must pass through, got: %v", v, err)
}
}
}

View File

@@ -219,7 +219,12 @@ var CsvPut = common.Shortcut{
}
cmd.MarkFlagsOneRequired("start-cell", "range")
},
Validate: validateViaInput(csvPutInput),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := guardCSVValueIsNotFilePath(runtime); err != nil {
return err
}
return validateViaInput(csvPutInput)(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -295,6 +300,36 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true
}
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
// would otherwise be written silently as the literal text "data.csv". It runs
// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is
// already its contents (a real CSV blob, never a path) and only a bare value
// reaches here unchanged. It flags the value only when it actually names an
// existing file in the cwd subtree; checking real existence (not name shape)
// means inline content that merely ends in a filename ("see config.json") is
// never misjudged. Fails open: any Stat error or a directory leaves the value
// untouched. Scoped to --csv only — no other flag is affected.
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("csv"))
if raw == "" {
return nil
}
fio := runtime.FileIO()
if fio == nil {
return nil
}
info, err := fio.Stat(raw)
if err != nil || info == nil || info.IsDir() {
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
}
return common.FlagErrorf(
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
raw, raw,
)
}
func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err

View File

@@ -33,6 +33,9 @@ var SlidesCreate = common.Shortcut{
// like wiki_move) so the pre-flight check fails fast and lark-cli's
// auth login --scope hint guides the user, instead of leaving an orphaned
// empty presentation when the in-flight upload 403s.
// NB: no drive scope here on purpose — slides creation never touches drive;
// the presentation URL is built locally (see Execute), so we don't gate a
// drive-free operation behind a drive scope.
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
@@ -205,29 +208,14 @@ var SlidesCreate = common.Shortcut{
}
}
// Fetch presentation URL via drive meta (best-effort)
if metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
); err == nil {
metas := common.GetSlice(metaData, "metas")
if len(metas) > 0 {
if meta, ok := metas[0].(map[string]interface{}); ok {
if url := common.GetString(meta, "url"); url != "" {
result["url"] = url
}
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {

View File

@@ -35,7 +35,6 @@ func TestSlidesCreateBasic(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -53,8 +52,10 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
if data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
// URL is built locally from the token (brand-standard host), not fetched from
// drive metas, so it is deterministic and needs no drive scope.
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -78,7 +79,6 @@ func TestSlidesCreateBotAutoGrant(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/pres_bot/members",
@@ -131,7 +131,6 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -168,7 +167,6 @@ func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -238,7 +236,6 @@ func TestSlidesCreateDefaultTitle(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -301,7 +298,6 @@ func TestSlidesCreateWithSlides(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
@@ -478,7 +474,6 @@ func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -551,7 +546,6 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
},
},
})
registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
@@ -580,8 +574,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
}
}
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
// locally from the token — no drive metas/batch_query call is made, so creation
// works for users who only authorized slides scopes. The httpmock registry has no
// batch_query stub registered; if the shortcut tried to call it, the request would
// fail the test (unregistered stub), proving the URL is built without a drive call.
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -592,24 +590,15 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_url",
"xml_presentation_id": "pres_local_url",
"revision_id": 1,
},
},
})
// batch_query returns an error — URL fetch should be silently skipped
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99999,
"msg": "no permission",
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No URL",
"--title", "Local URL",
"--as", "user",
})
if err != nil {
@@ -617,11 +606,11 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_no_url" {
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
if data["xml_presentation_id"] != "pres_local_url" {
t.Fatalf("xml_presentation_id = %v, want pres_local_url", data["xml_presentation_id"])
}
if _, ok := data["url"]; ok {
t.Fatalf("did not expect url when batch_query fails")
if data["url"] != "https://www.feishu.cn/slides/pres_local_url" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_local_url", data["url"])
}
}
@@ -672,22 +661,6 @@ func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buf
return parent.Execute()
}
// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL.
func registerBatchQueryStub(reg *httpmock.Registry, token, url string) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": token, "doc_type": "slides", "title": "", "url": url},
},
},
},
})
}
// decodeSlidesCreateEnvelope parses the JSON output and returns the data map.
func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
@@ -758,7 +731,6 @@ func TestSlidesCreateWithImagePlaceholders(t *testing.T) {
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img")
slidesJSON := `[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"10\"/><img src=\"@b.png\" topLeftX=\"20\"/></data></slide>",

View File

@@ -73,7 +73,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
## 最佳实践
- 文档标题从内容中自动提取XML `<title>` 或 Markdown `#`),不要在内容开头重复写标题
- **创建较长的文档时只建骨架**`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `docs +update --command append``block_insert_after` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
- **创建较长的文档时只建骨架**`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档
## 参考

View File

@@ -26,7 +26,7 @@
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
| `--pattern` | 视指令 | 匹配文本str_replace |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),-1 表示末尾 |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |
| `--revision-id` | 否 | 基准版本号,-1 = 最新(默认 `-1` |
@@ -40,8 +40,8 @@
| `block_replace` | 替换指定 block同一 block 仅限一次) | `--block-id` `--content` |
| `block_delete` | 删除指定 block逗号分隔可批量 | `--block-id` |
| `overwrite` | ⚠️ 清空文档后全文重写(可能丢失图片、评论) | `--content` |
| `append` | 在文档末尾追加内容(等价于 `block_insert_after --block-id -1` | `--content` |
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` + (`--content` `--src-block-ids`) |
| `append` | ⚠️ 在文档**末尾**追加内容(等价于 `block_insert_after --block-id -1`。**不适用于逐章填充**——逐章写入请用 `block_insert_after` 并指定对应标题的 `--block-id` | `--content` |
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` `--src-block-ids` |
## 指令示例
@@ -116,8 +116,9 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
### block_delete — 删除指定 block
```bash
# 删除多个块时用逗号 "," 分隔
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_delete \
--block-id "目标 block_id"
--block-id "block_id_1,block_id_2,block_id_3"
```
### overwrite — 全文覆盖

View File

@@ -45,6 +45,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
# 四、块级复制与移动
@@ -54,7 +55,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
## 复制block_copy_insert_after
- **基础标签**(块级标签、容器标签、行内组件):均支持复制
- **资源块**:仅 img、source、whiteboard、sheet、chat_card 支持复制task、bitable、base_ref、synced_reference、synced_source、okr 不支持复制
- **资源块**:仅 img、source、whiteboard、sheet、chat_card、sub-page-list 支持复制task、bitable、base_ref、synced_reference、synced_source、okr 不支持复制
使用 `docs +update --command block_copy_insert_after --block-id "<锚点>" --src-block-ids "id1,id2"`
@@ -166,4 +167,5 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
<task task-id="TASK_GUID"></task>
<chat_card chat-id="CHAT_ID"></chat_card>
<sub-page-list></sub-page-list>
```

View File

@@ -22,14 +22,14 @@
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block承载重要信息的章节优先规划画板
3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `<callout>` + 各级标题 + 每节一句占位摘要
- ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到第二波,由各 Agent 用 `docs +update --command append``block_insert_after` 分段写入。
- 完整内容留到第二波,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
### 第二波 — 内容撰写(并行 Agent
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、期望的 block 类型
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `docs +update --command append``block_insert_after` 写入
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
### 第三波 — 整合审查 + 画板意图识别(串行)

View File

@@ -20,6 +20,7 @@ metadata:
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
@@ -217,6 +218,9 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
- 使用 `drive file.comments batch_query` 是**已知评论 ID 后**的批量查询,需要传入具体的评论 ID 列表。
- 使用 `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论,或获取"最新/最后 N 条评论"等场景。
#### 评论定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [评论定位字段说明](references/lark-drive-comment-location.md),其他文档类型暂不支持返回定位字段。
#### Reaction / 表情场景
- 遇到评论 / 回复上的 reaction表情、各表情数量、谁点了什么、添加/删除表情)相关问题时,**先阅读 [lark-drive-reactions.md](../../skills/lark-drive/references/lark-drive-reactions.md) 了解如何使用**。

View File

@@ -0,0 +1,193 @@
# 文档评论定位字段
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时docx 文档的评论查询必须带 `need_relation=true`
## 适用范围
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation``parent_type``parent_token` 等字段。
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
## 调用方式
分页列出评论时,把 `need_relation` 放在 query params
```bash
lark-cli drive file.comments list \
--params '{"file_token":"<doc_token>","file_type":"docx","is_solved":false,"need_relation":true}'
```
已知评论 ID 批量查询时,把 `need_relation` 放在请求体里:
```bash
lark-cli drive file.comments batch_query \
--params '{"file_token":"<doc_token>","file_type":"docx"}' \
--data '{"comment_ids":["<comment_id>"],"need_relation":true}'
```
同时获取文档内容,并要求返回 block id
```bash
lark-cli docs +fetch --api-version v2 --doc '<doc_token_or_url>' --detail with-ids
```
## 字段含义
- `relation`:评论在文档内容中的结构化位置。`relation.relation` 是一个 JSON 字符串,需要再解析一次;其中 `positionInfo.blockID` 是最关键字段,用于匹配 `docs +fetch --detail with-ids` 返回的文档 block。
- `relation.content_deleted`:评论引用的内容是否已被删除。为 `true` 时,不要假设还能在当前正文中找到原位置。
- `parent_type`:评论所在的父级嵌入资源类型。常见值包括 `SHEET_BLOCK``BITABLE_BLOCK``WHITEBOARD_BLOCK`,表示评论落在文档内嵌电子表格、多维表格或画板内部。
- `parent_token`:父级嵌入资源 token。对 sheet / bitable / whiteboard 内部评论,服务端可能无法给出内部单元格、记录或画板节点的文档 block 级 `relation`,但可以通过 `parent_type` + `parent_token` 定位到文档里的父级嵌入 block。
## 准确度分级
输出定位结论时,必须区分以下三类,不要把弱推断说成精确定位:
| 等级 | 判定条件 | 输出口径 |
|---|---|---|
| `relation 精确` | `relation.relation` 中有 `positionInfo.blockID`,且能在 `docs +fetch --detail with-ids` 中匹配到同一 block | 可说“准确定位到 block” |
| `父级资源精确,内部需下钻` | 只有父级嵌入资源的 `blockID` / `parent_type` / `parent_token`,或内部资源的 `positionInfo` 为空 | 可说“准确定位到嵌入资源;内部单元格/记录/节点需用对应 skill 下钻确认” |
| `弱匹配/推断` | 只能依赖 `quote`、序号、当前展示顺序或文本搜索 | 必须标明“推断”,说明歧义来源和需要的补充信息 |
## 返回示例
普通 docx block 上的评论会返回 `relation`。注意 `relation.relation` 本身是字符串,需要再 JSON parse 一次:
```json
{
"comment_id": "7646774324967295982",
"quote": "code2",
"relation": {
"content_deleted": false,
"relation": "{\"22-doc_token_xxx\":{\"objType\":22,\"index\":2,\"objVersion\":10,\"positionInfo\":{\"blockID\":\"block_id_xxx\"}}}"
},
"parent_type": null,
"parent_token": null
}
```
`relation.relation` 再解析后,取 `positionInfo.blockID`
```json
{
"22-doc_token_xxx": {
"objType": 22,
"index": 2,
"objVersion": 10,
"positionInfo": {
"blockID": "block_id_xxx"
}
}
}
```
然后在 `docs +fetch --detail with-ids` 的结果里查找同一个 block id例如
```json
{
"block_id": "block_id_xxx",
"block_type": "code",
"text": "code1\ncode2"
}
```
嵌入 sheet / bitable / whiteboard 内部评论可能没有可用 `relation`,但会返回父级标记:
```json
{
"comment_id": "7646775036988148672",
"quote": "记录 2",
"relation": null,
"parent_type": "BITABLE_BLOCK",
"parent_token": "bitable_app_token_xxx_table_id_xxx"
}
```
这种情况下,用 `parent_type` 判断目标是嵌入资源,再用 `parent_token` 匹配 `docs +fetch --detail with-ids` 中的 bitable / sheet block。定位粒度是文档里的父级嵌入 block不是内部记录、字段或单元格。
画板内部评论的返回形态类似:
```json
{
"comment_id": "7646775036988148673",
"quote": "画板节点文本",
"relation": null,
"parent_type": "WHITEBOARD_BLOCK",
"parent_token": "whiteboard_token_xxx"
}
```
此时 `parent_token` 对应 `docs +fetch --detail with-ids` 结果中 `<whiteboard>``token` 属性,例如:
```xml
<whiteboard id="whiteboard_block_id_xxx" token="whiteboard_token_xxx"></whiteboard>
```
匹配到这个 `<whiteboard>` 后,`id` 就是文档正文里的父级画板 block id。定位粒度是文档里的画板 block如果需要继续定位到画板内部具体节点需要再用画板能力读取画板内部结构。
## 定位流程
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
2.`drive file.comments list``drive file.comments batch_query` 获取评论,并带 `need_relation=true`
3.`docs +fetch --api-version v2 --detail with-ids` 获取文档内容。
4. 对每条评论先看 `relation`
- 如果存在 `relation.relation`,解析这个 JSON 字符串。
- 从解析结果里取 `positionInfo.blockID`
-`docs +fetch` 结果中查找相同 block id这就是评论对应的文档 block。
5. 如果没有可用 `relation`,但有 `parent_type``parent_token`
- `SHEET_BLOCK`:定位到文档中的 sheet 嵌入 block`parent_token` 通常包含 sheet token 和 sheet id必要时取 `_` 前的 token 与文档 block 的嵌入资源 token 对比。
- `BITABLE_BLOCK`:定位到文档中的 bitable 嵌入 block`parent_token` 通常包含 bitable app token 和 table id必要时取 `_` 前的 token 与文档 block 的嵌入资源 token 对比。
- `WHITEBOARD_BLOCK`:定位到文档中的 whiteboard 嵌入 block`parent_token` 对应 `docs +fetch --detail with-ids``<whiteboard>``token` 属性。
- 这种场景能定位到父级嵌入 block但通常不能仅凭评论接口定位到嵌入资源内部的具体单元格、字段、记录或画板节点。
6. 只有在 `relation``parent_type``parent_token` 都缺失时,才退回使用 `quote` 文本做弱匹配;`quote` 是评论接口返回的引用文本字段。弱匹配不能区分多处相同文本。
## 嵌入资源内部定位
### Sheet 内部评论
- `parent_token` 常见格式是 `<spreadsheet_token>_<sheet_id>`;也可能在 `relation.relation` 中看到 `subToken``3-<spreadsheet_token>`
- 评论接口通常只把 `positionInfo.blockID` 指到文档里的 `<sheet>` block内部 sheet 的 `positionInfo` 可能为空。
- 如果 `quote``C3``A1` 这类单元格坐标,可拆出 `spreadsheet_token` / `sheet_id` 后用 `lark-sheets` 读取该单元格确认:
```bash
lark-cli sheets +read \
--spreadsheet-token '<spreadsheet_token>' \
--sheet-id '<sheet_id>' \
--range '<cell>'
```
- 准确度口径:父级 sheet block 可由 relation/parent token 精确定位;单元格坐标若只来自 `quote`,应说明“单元格来自 quote已通过 sheets 读取验证”,不要说它来自 `positionInfo`
### Bitable / Base 内部评论
- `parent_token` 常见格式是 `<base_token>_<table_id>`,其中 `table_id` 通常以 `tbl` 开头。解析时优先按最后一个 `_tbl` 边界拆分,避免 base token 内出现 `_` 时误拆。
- 评论接口可能只返回 `parent_type=BITABLE_BLOCK``parent_token`,没有 `relation`;即使有 relation也通常只足够定位到文档里的 `<bitable>` block。
- 下钻读取时切到 `lark-base`,最少确认表、字段、记录:
```bash
lark-cli base +table-list --base-token '<base_token>'
lark-cli base +field-list --base-token '<base_token>' --table-id '<table_id>'
lark-cli base +record-list --base-token '<base_token>' --table-id '<table_id>' --limit 200 --format json
```
- 如果 `quote` 是某个稳定业务值,优先用字段/记录数据做精确匹配;如果 `quote` 只是“第 N 条”“第 N 行”这类 UI 序号,只能基于当前记录顺序推断对应记录,必须输出为“推断”,并说明评论接口没有返回 `record_id` / `field_id`
- 如果 `record-list` 返回 `has_more=true`,不要基于第一页下全局结论;继续分页或说明只能覆盖已读取范围。
- 需要写入时,如果评论没有字段信息,不要自行猜字段;除非用户给出默认规则,否则请求用户确认字段,或明确说明将使用哪个字段作为默认。
### Whiteboard 内部评论
- `parent_token` 对应文档 XML 中 `<whiteboard token="...">`;先用它匹配文档里的 whiteboard block。
- 若要定位画板内部节点,切到 `lark-whiteboard` 读取 raw 节点结构:
```bash
lark-cli whiteboard +query \
--whiteboard-token '<whiteboard_token>' \
--output_as raw
```
- 如果 raw 节点中存在唯一匹配 `quote` 的文本节点,可定位到该节点;如果有多个相同文本节点,仍然是弱匹配,需要结合位置、样式、用户描述或人工确认。
- 修改画板节点前,先说明匹配到的节点 id 和文本;复杂画板不要只凭 `quote` 批量替换全部同名节点。
## 使用原则
- Review 文档时,不要只依赖 `quote` 文本定位评论;多处相同文本会产生歧义。
- 能拿到 `relation.positionInfo.blockID` 时,以 block id 为准,再用 block 内容理解上下文。
- 对嵌入 sheet / bitable / whiteboard 内的评论,以父级嵌入 block 作为文档正文定位点;如需继续定位到表格单元格、多维表格记录或画板内部节点,需要再调用对应 sheet / bitable / whiteboard 能力读取内部数据。

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