Compare commits

..

27 Commits

Author SHA1 Message Date
fangshuyu
b54abe817c feat(drive): harden inspect shortcut failures 2026-06-08 16:34:16 +08:00
evandance
076f4d579f feat(minutes,vc): emit typed error envelopes across both domains (#1234)
Failures from the minutes and video-conference commands now surface as
structured, typed errors carrying a stable category and subtype — spanning
input validation, missing permissions, network and file-I/O failures, and
remote API errors — so callers can branch on the error kind instead of
parsing free-form text. Batch commands report partial failures explicitly,
emitting per-item results with a non-zero exit instead of masking them.
2026-06-08 16:20:43 +08:00
SunPeiYang996
0c2fd08d5a feat:remove docs v1 api (#1291)
Change-Id: I29d0af3e5325261f94949d3ab3f65051fb6bd52b
2026-06-08 16:07:52 +08:00
liangshuo-1
9d845442ce feat: add skills command to read embedded skill content (#1318) 2026-06-08 13:58:45 +08:00
Max Huang
c07a14aa2b feat(lark-shared): document relative-path-only file arguments (#1319) 2026-06-08 13:19:03 +08:00
ethan-zhx
8b39f7243c feat: add iconpark lookup for lark slides (#1123) 2026-06-08 12:28:04 +08:00
liujinkun2025
e40ef66912 docs(lark-wiki): optimize skill guidance and routing boundaries (#1275)
- Add explicit NOT boundaries to the description and a dedicated
  "不在本 skill 范围" section: file upload -> lark-drive, content
  editing -> lark-doc / lark-sheets / lark-base.
- Move the Shortcuts table up, right after 快速决策, so command entry
  points are discoverable first; keep the member-add flow and
  target-semantics sections after it.
- Add an inline reminder under the delete-space guidance that a wiki
  URL / name is not a space_id and must be resolved via
  `wiki spaces get_node` first.
- Remove the duplicated permission (scope) table and the redundant
  schema note so auth/permission guidance stays centralized in
  lark-shared.
- Bump the skill version to 1.0.1.
- Keep skill-template/domains/wiki.md in sync with the SKILL.md
  introduction narrative.

Change-Id: If2b4341f350191ee0a65bf3a2cab9afa2b76d931
2026-06-08 11:10:59 +08:00
zhumiaoxin
e1bb9db552 feat(im): format feed group error handling (#1308) 2026-06-07 21:12:19 +08:00
zhangheng023
7c50b3d9e3 feat: fetch official skills index (#1301)
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.

Changes:

- Add official skills index JSON parsing in `internal/skillscheck/sync.go`

- Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go`

- Add reason-chain details when both discovery layers fall back to `fallbackFullInstall`

- Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go`

- Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go`

Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
2026-06-06 18:29:04 +08:00
evandance
5788a6c384 feat(im): return typed error envelopes across the im domain (#1230) 2026-06-06 17:07:57 +08:00
zhumiaoxin
bd07859c90 feat(im): cli support feed group (#1102)
Add IM feed group support documentation for lark-cli, making the raw im feed.groups.* APIs discoverable and easier for agents to use correctly.
2026-06-06 14:25:31 +08:00
evandance
8c3cba17b2 feat(task): emit typed error envelopes across the task domain (#1231)
Task commands now return structured, typed errors instead of the legacy
exit-code envelope: every failure carries a stable category, subtype, and
recovery hint, so callers can branch on the error class instead of parsing
messages. Exit codes derive from the error category — input validation exits 2,
a permission denial exits 3, other API errors exit 1.

Batch operations (adding tasks to a tasklist, creating a tasklist with tasks)
now report partial failure honestly: the per-item successes and failures stay
on stdout and the command exits non-zero instead of masking failures as a
success.
2026-06-05 22:30:45 +08:00
evandance
6367aaa0f5 feat(okr,whiteboard): emit typed error envelopes across both domains (#1236)
The okr and whiteboard commands now report every failure as a typed error
envelope. Invalid flags, malformed input, output-file conflicts, and API or
transport failures alike carry a stable category, subtype, the offending flag
or Lark error code, and a meaningful exit code — so scripts and agents can
branch on the error shape instead of scraping message strings.
2026-06-05 20:00:04 +08:00
qinxiaoyun
37b17f3d37 feat(events): add whiteboard event domain with per-board subscription (#1265)
Wire the board.whiteboard.updated_v1 EventKey into the consume pipeline so that lark-cli event consume automatically calls the per-whiteboard subscribe / unsubscribe OAPIs instead of requiring callers to manage server-side subscriptions out-of-band.

Change-Id: I94323807e8dc649d3296f6922311d2acaf92284e
2026-06-05 17:09:17 +08:00
evandance
be5527ca4e feat(im): add feed shortcut create, list, and remove shortcuts (#1273)
Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
2026-06-05 16:42:48 +08:00
fangshuyu-768
a75420f72c docs: add markdown domain template (#1293) 2026-06-05 15:48:01 +08:00
evandance
f3949f04c4 feat(calendar): emit typed error envelopes across the calendar domain (#1232)
Calendar commands now return structured, typed error envelopes for every
failure mode — input validation, internal faults, and API responses —
instead of legacy generic errors. Callers and AI agents get consistent
exit codes and a machine-readable shape (type / subtype / code / hint),
and can tell bad input, an internal fault, and an API rejection apart.
Validation errors are attributed to the offending flag.

Server-supplied error details (e.g. why an event time was rejected) are
surfaced on the typed error's hint via a shared classifier improvement
that benefits every domain. Multi-step operations (create-with-attendees
rollback, multi-field update) preserve the real failure's classification
and report which steps completed.

The whole calendar domain is now lint-locked against reintroducing legacy
error constructors.
2026-06-05 13:06:50 +08:00
caojie0621
62364fc320 fix(drive): use docs secure label read scope (#1281) 2026-06-05 12:48:22 +08:00
fangshuyu-768
2f4e2c3019 docs: improve lark-markdown skill guidance (#1279) 2026-06-05 12:34:56 +08:00
evandance
3990151122 feat(base): emit typed error envelopes across the base domain (#1248) 2026-06-05 11:40:00 +08:00
MaxHuang22
fa929f02d6 feat: clear recommend.allow scope auto-approve overrides (#1272)
The recommend.allow list in scope_overrides.json special-cased a set of
calendar/contact/mail scopes into the auto-approve set on top of the
platform recommendations in scope_priorities.json. Remove all entries so
no scopes are special-cased anymore; auto-approve now reflects only the
platform recommend=true scopes (plus the recommend.deny removals).

Update registry tests to use a recommend=true scope (sheets:spreadsheet:read)
as the auto-approve sample and assert the override allow set is empty.

Change-Id: Ic555a2c664e2dbd742f79712253f2918dfabf7ce
2026-06-05 11:37:46 +08:00
sang-neo03
a4a4bd6ee0 feat: check shortcut example commands against the live CLI tree (#1244)
Validate the example commands embedded in shortcut definitions (the
"Example: lark-cli ..." lines in each shortcut's Tips, shown in --help)
against the real command tree built by cmd.Build. Implemented entirely as
test-only code in cmd/ (package cmd_test), so it ships in no binary and is
not importable by product code; the truth source is cmd.Build, the same
tree the binary uses, so the check cannot drift. It runs in the standard
unit-test CI job (go test ./cmd/...); a renamed command or unaccepted flag
in an example fails that job.
2026-06-05 10:59:55 +08:00
zhoujunteng-max
ac116e7ca3 feat(drive): add drive preview and cover shortcuts and document quota details (#1259)
* feat: support get quota detail

* feat: add drive preview and cover shortcuts

- add `drive +preview` and `drive +cover` shortcuts
- wrap `preview_result` output with stable preview item fields
- support cover download via `preview_download` with validated preset mappings
- update lark-drive skill references for preview and cover usage

* fix(drive): classify cover 404 as failed precondition

* fix(drive): show preview download step in dry-run

* docs(drive): clarify quota details user-only usage

* fix(drive): soften cover 404 guidance
2026-06-04 21:08:59 +08:00
evandance
5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00
liangshuo-1
493b3cce95 chore(release): v1.0.48 (#1270) 2026-06-04 20:49:54 +08:00
zhangheng023
abc0553f21 fix: use json skills list during update (#1251)
* fix: use json skills list during update

* fix: preserve versioned skill names
2026-06-04 19:19:26 +08:00
xukuncx
a82a486508 feat(mail): preserve mailbox context in +triage output for public mailboxes (#1238)
When triaging a public/shared mailbox, downstream AI consumers (e.g.
mail +message) need the mailbox_id to construct correct API paths.
Previously the triage output only included message_id, causing
/user_mailboxes/me/messages/{id} lookups that fail for public mailboxes.

- Add mailbox_id field to every normalized message in structured output
- Add mailbox_id to top-level JSON/data output envelope
- Add mailbox_id to table rows when mailbox is not "me"
- Update stderr next-step tip to include --mailbox for non-me mailboxes
- Update next-page hint to include --mailbox for non-me mailboxes
- Add unit tests covering list, search, and public mailbox paths
- Update triage skill docs to show mailbox_id in output examples
2026-06-04 18:27:13 +08:00
332 changed files with 63665 additions and 6773 deletions

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/calendar/helpers\.go|shortcuts/drive/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
# 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/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -115,17 +115,15 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file.
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
@@ -1009,6 +1026,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45

View File

@@ -6,6 +6,7 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -16,6 +17,7 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
@@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -140,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

113
cmd/cmdexample_test.go Normal file
View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Type: "api",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
Message: "Bot/User can NOT be out of the chat.",
},
})
}

View File

@@ -70,7 +70,7 @@ func printResourceList(w io.Writer, spec map[string]interface{}, mode core.Stric
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetMethodDescription(name, resName, methodName, m)
desc := registry.GetStrFromMap(m, "description")
danger := ""
if d, _ := m["danger"].(bool); d {
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
@@ -94,7 +94,7 @@ func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, method
methodPath := registry.GetStrFromMap(method, "path")
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetMethodDescription(specName, resName, methodName, method)
desc := registry.GetStrFromMap(method, "description")
isFileUpload, fileFieldNames := hasFileFields(method)
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
@@ -679,7 +679,7 @@ func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetMethodDescription(serviceName, resName, mName, m)
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)

View File

@@ -196,25 +196,6 @@ func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
}
}
func TestPrintMethodDetail_UsesDescriptionOverrideWhenMetadataIsEmpty(t *testing.T) {
spec := map[string]interface{}{
"name": "drive",
"servicePath": "/open-apis/drive/v1",
}
method := map[string]interface{}{
"httpMethod": "PATCH",
"path": "files/{file_token}",
"description": "",
}
var out bytes.Buffer
printMethodDetail(&out, spec, "files", "patch", method)
if !strings.Contains(out.String(), "修改文件标题") {
t.Fatalf("pretty output = %q, want to contain %q", out.String(), "修改文件标题")
}
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -140,10 +140,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
specName := registry.GetStrFromMap(spec, "name")
desc := registry.GetMethodDescription(specName, resName, name, method)
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{

View File

@@ -166,19 +166,6 @@ func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
}
}
func TestNewCmdServiceMethod_UsesDescriptionOverrideWhenMetadataIsEmpty(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "", "httpMethod": "PATCH"}, "patch", "files", nil)
if cmd.Short != "修改文件标题" {
t.Fatalf("Short = %q, want %q", cmd.Short, "修改文件标题")
}
if !strings.Contains(cmd.Long, "修改文件标题") {
t.Fatalf("Long = %q, want to contain %q", cmd.Long, "修改文件标题")
}
}
func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

183
cmd/skill/skill.go Normal file
View File

@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skill implements the `lark-cli skills` command group, which serves
// binary-embedded skill content to AI agents. The package is "skill"; the
// user-facing verb is "skills".
package skill
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillcontent"
"github.com/spf13/cobra"
)
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
if f.SkillContent == nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"skill content not embedded in this build")
}
return skillcontent.New(f.SkillContent), nil
}
type readEnvelope struct {
Skill string `json:"skill"`
Path string `json:"path"`
Content string `json:"content"`
Guidance string `json:"guidance,omitempty"`
}
type listEnvelope struct {
OK bool `json:"ok"`
Skills []skillcontent.SkillInfo `json:"skills"`
Count int `json:"count"`
}
type listPathEnvelope struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []skillcontent.DirEntry `json:"entries"`
Count int `json:"count"`
}
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
Short: "Read embedded skill content (list / read)",
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
"the CLI binary at build time, so it stays in sync with the CLI version. " +
"Machine resources such as assets/ and scripts/ are not embedded.",
}
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(newListCmd(f), newReadCmd(f))
return cmd
}
func newListCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list [name[/path]]",
Short: "List skills, or list one layer under a skill path (like ls)",
Example: ` lark-cli skills list # all skills: name, description, version
lark-cli skills list lark-doc # one layer under a skill (like ls)
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"list takes at most 1 argument: [name[/path]]").
WithHint("run 'lark-cli skills list --help'")
}
r, err := newReader(f)
if err != nil {
return err
}
if len(args) == 0 {
skills, err := r.List()
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
return nil
}
entries, listed, err := r.ListPath(args[0])
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
return nil
},
}
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "read <name>[/<path>] [path]",
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
lark-cli skills read lark-doc --json # JSON envelope`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
name, relpath, err := parseReadTarget(args)
if err != nil {
return err
}
r, err := newReader(f)
if err != nil {
return err
}
var content []byte
var pathOut string
if relpath == "" {
content, err = r.ReadSkill(name)
pathOut = "SKILL.md"
} else {
content, pathOut, err = r.ReadReference(name, relpath)
}
if err != nil {
return err
}
isMain := pathOut == "SKILL.md"
if asJSON {
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
if isMain {
env.Guidance = readGuidance(name)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
if _, err := f.IOStreams.Out.Write(content); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
}
if isMain {
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
func parseReadTarget(args []string) (name, relpath string, err error) {
switch len(args) {
case 1:
name, relpath = skillcontent.SplitArg(args[0])
return name, relpath, nil
case 2:
return args[0], args[1], nil
default:
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
WithHint("run 'lark-cli skills read --help'")
}
}
// readGuidance routes cross-skill "../lark-foo/..." references back through
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
// relative form must be rewritten.
func readGuidance(name string) string {
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
}

306
cmd/skill/skill_test.go Normal file
View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skill
import (
"encoding/json"
"io"
"io/fs"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
)
// calFS is the default single-skill content tree for these tests. The embedded
// FS is now injected through the Factory (no package global), so tests pass it
// explicitly to run() — nothing is shared, so they are safe under -parallel.
func calFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
}
}
// run executes the skills command tree against the given content FS (may be nil
// to exercise the not-embedded path) and returns stdout/stderr/err.
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
t.Helper()
// Isolate CLI config state so tests never read/write the real config dir
// (repo convention).
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
f.SkillContent = fsys
cmd := NewCmdSkill(f)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err = cmd.Execute()
return out.String(), errOut.String(), err
}
func TestSkillList(t *testing.T) {
stdout, _, err := run(t, calFS(), "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
// "ok" is an explicit success marker (the list envelope is a typed struct;
// no automatic _notice attaches).
if !got.OK {
t.Error("expected ok=true in list envelope")
}
if got.Count != 1 || len(got.Skills) != 1 {
t.Fatalf("count: got %d", got.Count)
}
if got.Skills[0]["name"] != "lark-calendar" {
t.Errorf("name: got %v", got.Skills[0]["name"])
}
// Top-level list carries version + metadata, not a references list.
if _, ok := got.Skills[0]["references"]; ok {
t.Error("top-level list must not include references")
}
if got.Skills[0]["version"] != "1.0.0" {
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
}
if _, ok := got.Skills[0]["metadata"]; !ok {
t.Error("expected metadata in list entry")
}
}
func TestSkillListJSONFlagAccepted(t *testing.T) {
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
// so it stays symmetric with read --json.
stdout, _, err := run(t, calFS(), "list", "--json")
if err != nil {
t.Fatalf("list --json error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Count != 1 {
t.Errorf("envelope: %+v", got)
}
}
func TestSkillListPath(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
if err != nil {
t.Fatalf("list <name> error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
} `json:"entries"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Path != "lark-calendar" {
t.Errorf("envelope: %+v", got)
}
// One layer under the skill root: SKILL.md (file) + references (dir).
if got.Count != 2 || len(got.Entries) != 2 {
t.Fatalf("entries: got %+v", got.Entries)
}
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
t.Errorf("entry[0]: got %+v", got.Entries[0])
}
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
t.Errorf("entry[1]: got %+v", got.Entries[1])
}
}
func TestSkillListPathUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "list", "no-such-skill")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("expected 'unknown skill' error, got %v", err)
}
}
func TestSkillListPathTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
if err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("expected 'invalid path' error, got %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillListTooManyArgs(t *testing.T) {
_, _, err := run(t, calFS(), "list", "a", "b")
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
}
}
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
// omitted from the catalog (no blank entry).
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
fsys := fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
}
stdout, _, err := run(t, fsys, "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
}
}
func TestSkillReadRaw(t *testing.T) {
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
if err != nil {
t.Fatalf("read error: %v", err)
}
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
t.Errorf("raw output: got %q", stdout)
}
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
if strings.Contains(stdout, "Tip:") {
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
}
// Guidance goes to stderr: own files via `skills read <name> ...`, and
// cross-skill refs routed to `skills read <other-skill> ...` (version-
// consistent), not "read directly".
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
}
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
}
if strings.Contains(stderr, "instead of opening them directly") ||
strings.Contains(stderr, "read those directly") {
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
}
}
func TestSkillReadJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
if err != nil {
t.Fatalf("read --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v", e)
}
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
t.Errorf("envelope: %+v", got)
}
// Guidance is a separate field, not merged into content.
if got.Guidance == "" {
t.Error("expected guidance field for main SKILL.md")
}
if strings.Contains(got.Content, "Tip:") {
t.Error("guidance must not be merged into content")
}
}
func TestSkillReadFile(t *testing.T) {
// Both the 2-arg and slash forms read the same file, with no guidance tip.
for _, args := range [][]string{
{"read", "lark-calendar", "references/agenda.md"},
{"read", "lark-calendar/references/agenda.md"},
} {
stdout, stderr, err := run(t, calFS(), args...)
if err != nil {
t.Fatalf("read %v error: %v", args, err)
}
if stdout != "# Agenda" {
t.Errorf("read %v output: got %q", args, stdout)
}
// Reference reads carry no guidance on either stream.
if strings.Contains(stderr, "Tip:") {
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
}
}
}
func TestSkillReadFileJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
if err != nil {
t.Fatalf("read file --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
t.Errorf("envelope: %+v", got)
}
// Reference reads do not carry the guidance tip.
if got.Guidance != "" {
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
}
}
func TestSkillReadUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "read", "no-such")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown skill") {
t.Errorf("err: %v", err)
}
}
func TestSkillReadMissingArg(t *testing.T) {
_, _, err := run(t, calFS(), "read")
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
t.Fatalf("expected arg error, got %v", err)
}
}
func TestSkillReadTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("err: %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillNilContentFS(t *testing.T) {
_, _, err := run(t, nil, "list")
if err == nil {
t.Fatal("expected error when SkillContent is nil")
}
if !strings.Contains(err.Error(), "not embedded") {
t.Errorf("err: %v", err)
}
}

View File

@@ -49,18 +49,29 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
return r
}
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:
@@ -476,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
@@ -808,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -860,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -1004,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1042,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
@@ -1086,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1145,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1194,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)

View File

@@ -8,6 +8,7 @@ import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
)
@@ -17,6 +18,7 @@ func init() {
im.Keys(),
minutes.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
for _, k := range keys {

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
type BoardWhiteboardUpdatedV1Data struct {
// WhiteboardID is the id of the whiteboard whose content was updated.
WhiteboardID string `json:"whiteboard_id"`
// OperatorIDs lists the operators that produced this update batch.
OperatorIDs []OperatorID `json:"operator_ids"`
}
// OperatorID identifies an operator that produced the whiteboard update,
// expressed in the three Lark identity formats.
type OperatorID struct {
// OpenID is the operator's open_id within the current app.
OpenID string `json:"open_id"`
// UnionID is the operator's union_id across apps under the same ISV.
UnionID string `json:"union_id"`
// UserID is the operator's user_id within the tenant.
UserID string `json:"user_id"`
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
// cleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const cleanupTimeout = 5 * time.Second
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
// and returns a cleanup that invokes the matching unsubscribe.
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
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")
}
whiteboardID := params["whiteboard_id"]
if whiteboardID == "" {
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
}
encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/event"
)
// recordedCall captures a single APIClient invocation for assertion.
type recordedCall struct {
method string
path string
body interface{}
}
// fakeAPIClient is a minimal event.APIClient stub that records calls and
// can be configured to fail when the request path matches errOnPath.
type fakeAPIClient struct {
mu sync.Mutex
calls []recordedCall
errOnPath string
}
// CallAPI records the invocation and optionally returns a simulated error
// when the path contains the configured errOnPath substring.
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
return nil, errors.New("simulated subscribe failure")
}
return json.RawMessage(`{}`), nil
}
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
// PreConsume hook fails fast with an actionable error when whiteboard_id
// is absent from the params map.
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
if err == nil {
t.Fatalf("expected error when whiteboard_id missing")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup on error")
}
if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, got: %v", err)
}
}
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
// returns an error when the runtime APIClient dependency is missing.
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error when runtime client is nil")
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
// failed subscribe call surfaces the error and skips registering a cleanup,
// so no spurious unsubscribe is invoked.
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{errOnPath: "/subscribe"}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error from subscribe call")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup when subscribe fails")
}
// only the failed subscribe call should have been made; no unsubscribe.
if len(rt.calls) != 1 {
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
// happy-path: subscribe is called once with the correct method/path/body,
// and the returned cleanup invokes the matching unsubscribe.
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanup == nil {
t.Fatalf("expected non-nil cleanup")
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
}
got := rt.calls[0]
if got.method != "POST" {
t.Errorf("subscribe method: got %q, want POST", got.method)
}
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
if got.path != wantSubPath {
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
}
body, _ := got.body.(map[string]string)
if body["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
}
cleanup()
if len(rt.calls) != 2 {
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
}
got2 := rt.calls[1]
if got2.method != "POST" {
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
}
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
if got2.path != wantUnsubPath {
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
}
body2, _ := got2.body.(map[string]string)
if body2["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
}
}
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
// whiteboard_id values containing reserved URL characters are properly
// path-segment encoded so they cannot escape into adjacent path segments.
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.calls))
}
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
}
}
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
// required whiteboard_id parameter.
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keys := Keys()
for _, k := range keys {
if k.Key == eventTypeWhiteboardUpdated {
if k.PreConsume == nil {
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
}
if len(k.Params) == 0 {
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
}
var found bool
for _, p := range k.Params {
if p.Name == "whiteboard_id" && p.Required {
found = true
}
}
if !found {
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
}
return
}
}
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
}
// 确保 event.APIClient 接口与本测试 mock 一致。
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package whiteboard registers Board-domain EventKeys.
package whiteboard
import (
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
// Keys returns all Board-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeWhiteboardUpdated,
DisplayName: "Whiteboard updated",
Description: "Pushed when the whiteboard content is updated.",
EventType: eventTypeWhiteboardUpdated,
Params: []event.ParamDef{
{
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
},
},
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
"/event/operator_ids/*/open_id": {Kind: "open_id"},
"/event/operator_ids/*/union_id": {Kind: "union_id"},
"/event/operator_ids/*/user_id": {Kind: "user_id"},
},
},
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
},
}
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -43,6 +44,8 @@ type Factory struct {
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.

View File

@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Troubleshooter = ts
}
}
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
// returns these as free-text reason strings with no machine-readable field
// name (verified for code 190014:
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
// prompt — rather than fabricated structured params. Lifted before the
// category switch so any classified arm inherits it; the CategoryAPI arm
// below prefers this server detail over the context-free APIHint default.
detailHint := liftErrorDetailValues(resp)
if detailHint != "" {
base.Hint = detailHint
}
switch meta.Category {
case errs.CategoryAuthorization:
@@ -129,7 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
// A server-supplied detail (lifted into base.Hint above) wins over the
// context-free APIHint default; only fall back to APIHint when absent.
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
@@ -214,6 +230,10 @@ func stringFromAny(v any) string {
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
@@ -244,6 +264,8 @@ func APIHint(subtype errs.Subtype) string {
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}
@@ -256,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
}
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
// Permission categories have authoritative recovery guidance (scopes to
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
@@ -364,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
return "check the calling identity has the required scope"
}
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
// strings and joins them with "; ". Returns "" when the structure is absent or
// carries no non-empty value. The shape (verified for code 190014) is
// {"error":{"details":[{"value":"<reason>"}]}}.
func liftErrorDetailValues(resp map[string]any) string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return ""
}
details, ok := errBlock["details"].([]any)
if !ok || len(details) == 0 {
return ""
}
var values []string
for _, d := range details {
m, ok := d.(map[string]any)
if !ok {
continue
}
if v, _ := m["value"].(string); v != "" {
values = append(values, v)
}
}
return strings.Join(values, "; ")
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string {

View File

@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
}
}
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
// resp.error.details[].value into Problem.Hint when the response routes to the
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
// — only a human-readable reason string, no machine-readable field name. It is
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
// structured params.
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "end_time should be later than start_time"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
}
}
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
// detail values are joined with "; " into a single Hint, and empty values are
// skipped.
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "first reason"},
map[string]any{"value": ""},
map[string]any{"value": "second reason"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "first reason; second reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
}
}
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
// the details array (not a JSON object) are skipped rather than panicking, and
// well-formed siblings still surface in the Hint.
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
"i am a bare string, not an object",
map[string]any{"value": "the real reason"},
42,
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "the real reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
}
}
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
// block, a non-array details field, and an empty details array all leave the
// Hint untouched (no lifted detail) instead of erroring.
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
// With no liftable detail, the Hint must not echo a server detail.
if strings.Contains(p.Hint, "nope") {
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
}
})
}
}
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
// when the upstream response omits it — wire envelope must omit the field.
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var calendarCodeMeta = map[int]CodeMeta{
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
}
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
// via the codemeta_calendar.go init() merge to its expected
// Category/Subtype/Retryable.
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 190014: calendar "invalid params" with a field-level detail
// (error.details[].value) lifted into Hint by BuildAPIError.
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
var mailCodeMeta = map[int]CodeMeta{
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
}
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
// Only codes whose meaning is stable across minutes endpoints are registered;
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
// Command-specific messages, hints, and subtypes are layered on top via
// per-command enrichment.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var minutesCodeMeta = map[int]CodeMeta{
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
}
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }

View File

@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
}
}
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
if got, ok := LookupCodeMeta(2091001); ok {
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
}
}
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050)
if !ok {

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes (e.g. 124002 "recording still generating", which has no
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
// per-command enrichment for a retry hint.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var vcCodeMeta = map[int]CodeMeta{
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
}
func init() { mergeCodeMeta(vcCodeMeta, "vc") }

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import "strings"
var methodDescriptionOverrides = map[string]string{
"drive.files.patch": "修改文件标题",
}
// GetMethodDescription returns the method description from metadata, falling back
// to a curated override when the upstream meta has an empty description.
func GetMethodDescription(service, resource, method string, meta map[string]interface{}) string {
if desc := strings.TrimSpace(GetStrFromMap(meta, "description")); desc != "" {
return desc
}
return methodDescriptionOverrides[service+"."+resource+"."+method]
}

View File

@@ -223,24 +223,6 @@ func TestFilterScopes_TooFewParts(t *testing.T) {
}
}
func TestGetMethodDescription_UsesOverrideWhenMetadataIsEmpty(t *testing.T) {
got := GetMethodDescription("drive", "files", "patch", map[string]interface{}{
"description": " ",
})
if got != "修改文件标题" {
t.Fatalf("GetMethodDescription() = %q, want %q", got, "修改文件标题")
}
}
func TestGetMethodDescription_PrefersMetadataDescription(t *testing.T) {
got := GetMethodDescription("drive", "files", "patch", map[string]interface{}{
"description": "Rename a file",
})
if got != "Rename a file" {
t.Fatalf("GetMethodDescription() = %q, want %q", got, "Rename a file")
}
}
// --- Auto-approve functions ---
func TestLoadAutoApproveSet(t *testing.T) {
@@ -249,14 +231,9 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Fatal("expected non-empty auto-approve set")
}
// From scope_overrides.json allow list
if !aaSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
}
// Verify allow list entries are present
// From scope_priorities.json recommend=="true"
if !aaSet["sheets:spreadsheet:read"] {
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
}
t.Logf("Auto-approve set has %d scopes", len(aaSet))
@@ -275,16 +252,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
if len(allowSet) == 0 {
t.Fatal("expected non-empty override allow set")
}
// Known entries from scope_overrides.json
if !allowSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in allow set")
}
if !allowSet["mail:event"] {
t.Error("expected mail:event in allow set")
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}
@@ -295,9 +266,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (in allow list)
if !IsAutoApproveScope("calendar:calendar.event:create") {
t.Error("expected calendar:calendar.event:create to be auto-approve")
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
}
// Completely unknown scope
@@ -308,9 +279,8 @@ func TestIsAutoApproveScope(t *testing.T) {
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"calendar:calendar.event:create", // auto-approve (in allow list)
"zzz:unknown:scope", // not in auto-approve
"sheets:spreadsheet:read", // auto-approve (in allow list)
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
}
result := FilterAutoApproveScopes(scopes)
@@ -318,10 +288,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that calendar:calendar.event:create is included
// Check that sheets:spreadsheet:read is included
found := false
for _, s := range result {
if s == "calendar:calendar.event:create" {
if s == "sheets:spreadsheet:read" {
found = true
}
// Ensure unknown scopes are not included
@@ -330,7 +300,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
}
}
if !found {
t.Error("expected calendar:calendar.event:create in result")
t.Error("expected sheets:spreadsheet:read in result")
}
}

View File

@@ -12,25 +12,7 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [
"calendar:calendar.event:create",
"calendar:calendar.event:delete",
"calendar:calendar.event:read",
"calendar:calendar.event:update",
"calendar:calendar.free_busy:read",
"calendar:calendar:create",
"calendar:calendar:delete",
"calendar:calendar:read",
"calendar:calendar:update",
"contact:user.basic_profile:readonly",
"mail:event",
"mail:user_mailbox.mail_contact:read",
"mail:user_mailbox.mail_contact:write",
"mail:user_mailbox.message.address:read",
"mail:user_mailbox.message.body:read",
"mail:user_mailbox.message.subject:read",
"mail:user_mailbox.message:readonly"
],
"allow": [],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -10,10 +10,13 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -37,9 +40,15 @@ const (
)
const (
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
skillsIndexMaxBodySize = 1 << 20
verifyTimeout = 10 * time.Second
)
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
)
// DetectResult holds installation detection results.
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
}
r := &NpmResult{}
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
}
client := transport.NewHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
}
return nil
}
resp, err := client.Do(req)
if err != nil {
r.Err = err
return r
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
return r
}
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
if _, err := io.Copy(&r.Stdout, limited); err != nil {
r.Err = err
return r
}
if r.Stdout.Len() > skillsIndexMaxBodySize {
r.Stdout.Reset()
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
return r
}
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
@@ -165,6 +222,10 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

View File

@@ -4,12 +4,18 @@
package selfupdate
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs"
)
@@ -188,6 +194,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills ls -g",
},
{
name: "list global json",
run: func(u *Updater) *NpmResult {
return u.ListGlobalSkillsJSON()
},
want: "-y skills ls -g --json",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
@@ -225,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
}
}
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
}
}
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
}
if result.Stdout.Len() != 0 {
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
}
}
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
oldTimeout := skillsIndexFetchTimeout
officialSkillsIndexURL = server.URL
skillsIndexFetchTimeout = 50 * time.Millisecond
t.Cleanup(func() {
officialSkillsIndexURL = oldURL
skillsIndexFetchTimeout = oldTimeout
})
result := New().ListOfficialSkillsIndex()
var netErr net.Error
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
}
}
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
}
}
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
return r
}}).ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if !strings.Contains(result.Stdout.String(), "override-skill") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skillcontent reads embedded skill content from an injected fs.FS
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
package skillcontent
import (
"io/fs"
"path"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"gopkg.in/yaml.v3"
)
type Reader struct {
fsys fs.FS
}
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
type SkillInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
// fed straight back into `read`.
type DirEntry struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
}
func (r *Reader) List() ([]SkillInfo, error) {
entries, err := fs.ReadDir(r.fsys, ".")
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
}
out := make([]SkillInfo, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
// Skip dirs that aren't real skills (no SKILL.md).
if info, ok := r.skillInfo(e.Name()); ok {
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
if err != nil {
return SkillInfo{}, false
}
desc, version, metadata := parseFrontmatter(data)
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
}
// ListPath lists one directory layer (no recursion) under "<name>" or
// "<name>/<sub>", returning the entries and the cleaned path listed.
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
name, sub := SplitArg(arg)
if err := r.ensureSkill(name); err != nil {
return nil, "", err
}
dir := name
if sub != "" {
cleaned, err := cleanSubPath(sub)
if err != nil {
return nil, "", err
}
dir = name + "/" + cleaned
info, err := fs.Stat(r.fsys, dir)
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"path %q not found in skill %q", sub, name).
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
}
if !info.IsDir() {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
}
}
entries, err := fs.ReadDir(r.fsys, dir)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
out := make([]DirEntry, 0, len(entries))
for _, e := range entries {
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, dir, nil
}
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
// separator is a bare skill name (rest "").
func SplitArg(arg string) (name, rest string) {
name, rest, _ = strings.Cut(arg, "/")
return name, rest
}
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
// unparseable frontmatter yields ("", "", nil), never an error.
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
lines := strings.Split(string(skillMD), "\n")
if strings.TrimRight(lines[0], "\r") != "---" {
return "", "", nil
}
block := make([]string, 0, len(lines))
closed := false
for _, ln := range lines[1:] {
if strings.TrimRight(ln, "\r") == "---" {
closed = true
break
}
block = append(block, ln)
}
if !closed {
return "", "", nil
}
var fm struct {
Description string `yaml:"description"`
Version string `yaml:"version"`
Metadata map[string]any `yaml:"metadata"`
}
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
return "", "", nil
}
return fm.Description, fm.Version, fm.Metadata
}
func (r *Reader) ReadSkill(name string) ([]byte, error) {
if err := r.ensureSkill(name); err != nil {
return nil, err
}
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
return data, nil
}
func (r *Reader) ensureSkill(name string) error {
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return unknownSkill(name)
}
info, err := fs.Stat(r.fsys, name)
if err != nil || !info.IsDir() {
return unknownSkill(name)
}
return nil
}
func unknownSkill(name string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
WithHint("run 'lark-cli skills list' to see available skills")
}
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
func cleanSubPath(relpath string) (string, error) {
cleaned := path.Clean(relpath)
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
// survives; reject it explicitly alongside "../".
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid path %q: must be a relative path without '..'", relpath)
}
return cleaned, nil
}
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
if err := r.ensureSkill(name); err != nil {
return nil, "", err
}
cleaned, err := cleanSubPath(relpath)
if err != nil {
return nil, "", err
}
full := name + "/" + cleaned
info, err := fs.Stat(r.fsys, full)
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"reference %q not found in skill %q", relpath, name).
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
}
if info.IsDir() {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"reference %q is a directory, not a file", relpath)
}
data, err := fs.ReadFile(r.fsys, full)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
return data, cleaned, nil
}

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillcontent
import (
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/errs"
)
func testFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
"lark-calendar/references/create.md": {Data: []byte("# Create")},
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
"lark-im/references/send.md": {Data: []byte("# Send")},
}
}
func TestList(t *testing.T) {
r := New(testFS())
skills, err := r.List()
if err != nil {
t.Fatalf("List() error: %v", err)
}
if len(skills) != 2 {
t.Fatalf("got %d skills, want 2", len(skills))
}
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
t.Fatalf("skills not sorted by name: %v", skills)
}
if skills[0].Description != "Calendar skill" {
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
}
// version is the frontmatter `version:` field, passed through for drift checks.
if skills[0].Version != "1.0.0" {
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
}
// metadata is the frontmatter `metadata:` block, passed through verbatim.
if skills[0].Metadata == nil {
t.Fatal("expected metadata for lark-calendar")
}
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
}
// No frontmatter → empty description and nil metadata (omitted from JSON).
if skills[1].Description != "" {
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
}
if skills[1].Metadata != nil {
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
}
if skills[1].Version != "" {
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
}
}
func TestListPath(t *testing.T) {
r := New(testFS())
// Skill root: direct children only (one layer), each path skill-prefixed.
entries, listed, err := r.ListPath("lark-calendar")
if err != nil {
t.Fatalf("ListPath root error: %v", err)
}
if listed != "lark-calendar" {
t.Errorf("listed path: got %q", listed)
}
want := map[string]bool{ // path → isDir
"lark-calendar/SKILL.md": false,
"lark-calendar/references": true,
"lark-calendar/assets": true,
}
if len(entries) != len(want) {
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
}
for _, e := range entries {
isDir, ok := want[e.Path]
if !ok {
t.Errorf("unexpected entry %q", e.Path)
continue
}
if e.IsDir != isDir {
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
}
}
// Entries are sorted by path.
if entries[0].Path != "lark-calendar/SKILL.md" {
t.Errorf("entries not sorted: %v", entries)
}
// Subdirectory: one layer under <name>/<subpath>.
subEntries, subListed, err := r.ListPath("lark-calendar/references")
if err != nil {
t.Fatalf("ListPath subdir error: %v", err)
}
if subListed != "lark-calendar/references" {
t.Errorf("listed subpath: got %q", subListed)
}
if len(subEntries) != 2 ||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
subEntries[1].Path != "lark-calendar/references/create.md" {
t.Errorf("subdir entries: got %v", subEntries)
}
// Unknown skill → typed validation error.
if _, _, err := r.ListPath("no-such-skill"); err == nil {
t.Error("expected error for unknown skill")
} else {
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Errorf("expected *errs.ValidationError, got %T", err)
}
}
// Path that points at a file (not a dir) → validation error.
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
t.Error("expected error listing a file")
} else if !strings.Contains(err.Error(), "is a file") {
t.Errorf("message: got %q", err.Error())
}
// Nonexistent subpath → validation error.
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
t.Error("expected not-found error")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("message: got %q", err.Error())
}
// Traversal in the subpath is rejected, no listing leaked.
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
entries, _, err := r.ListPath(bad)
if err == nil {
t.Errorf("expected rejection for %q", bad)
}
if entries != nil {
t.Errorf("entries leaked for %q: %v", bad, entries)
}
}
}
func TestReadSkill(t *testing.T) {
r := New(testFS())
data, err := r.ReadSkill("lark-calendar")
if err != nil {
t.Fatalf("ReadSkill error: %v", err)
}
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
t.Errorf("unexpected content: %q", string(data))
}
_, err = r.ReadSkill("no-such-skill")
if err == nil {
t.Fatal("expected error for unknown skill")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
t.Errorf("message: got %q", verr.Message)
}
if _, err := r.ReadSkill("../etc"); err == nil {
t.Error("expected error for name with separator")
}
}
func TestReadReference(t *testing.T) {
r := New(testFS())
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
if err != nil {
t.Fatalf("ReadReference error: %v", err)
}
if string(data) != "# Agenda" {
t.Errorf("content: got %q", string(data))
}
if cleaned != "references/agenda.md" {
t.Errorf("cleaned path: got %q", cleaned)
}
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
t.Error("expected not-found error")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("message: got %q", err.Error())
}
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
t.Error("expected directory error")
} else if !strings.Contains(err.Error(), "is a directory") {
t.Errorf("message: got %q", err.Error())
}
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
data, _, err := r.ReadReference("lark-calendar", bad)
if err == nil {
t.Errorf("expected rejection for %q", bad)
}
if data != nil {
t.Errorf("content leaked for %q: %q", bad, string(data))
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Errorf("expected validation error for %q, got %T", bad, err)
}
}
}
func TestParseFrontmatter(t *testing.T) {
cases := []struct {
name string
input string
wantDesc string
wantVer string
wantHasMeta bool
}{
{
name: "description, version and metadata",
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
wantDesc: "My skill",
wantVer: "2.1.0",
wantHasMeta: true,
},
{
name: "description only, no metadata",
input: "---\ndescription: Plain\n---\nbody\n",
wantDesc: "Plain",
},
{
name: "no frontmatter",
input: "no frontmatter here\n",
},
{
name: "unclosed frontmatter",
input: "---\ndescription: Never closed\n",
},
{
name: "malformed YAML inside frontmatter",
input: "---\n: bad: yaml: [\n---\nbody\n",
},
{
name: "CRLF line endings",
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
wantDesc: "CRLF skill",
wantHasMeta: true,
},
{
name: "empty input",
input: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
desc, ver, meta := parseFrontmatter([]byte(tc.input))
if desc != tc.wantDesc {
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
}
if ver != tc.wantVer {
t.Errorf("version = %q, want %q", ver, tc.wantVer)
}
if (meta != nil) != tc.wantHasMeta {
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
}
})
}
}
func TestReadSkillMissingFile(t *testing.T) {
// Use a separate MapFS so testFS() (and TestList) are unaffected.
emptyFS := fstest.MapFS{
"lark-empty/references/x.md": {Data: []byte("# X")},
}
r := New(emptyFS)
_, err := r.ReadSkill("lark-empty")
if err == nil {
t.Fatal("expected error when SKILL.md is absent")
}
var ierr *errs.InternalError
if !errors.As(err, &ierr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
}

View File

@@ -4,6 +4,7 @@
package skillscheck
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -57,6 +58,52 @@ func ParseSkillsList(text string) []string {
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type globalSkill struct {
Name string `json:"name"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil
}
seen := map[string]bool{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
type officialSkill struct {
Name string `json:"name"`
}
type officialIndex struct {
Skills []officialSkill `json:"skills"`
}
var index officialIndex
if err := json.Unmarshal([]byte(text), &index); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, skill := range index.Skills {
candidate := strings.TrimSpace(skill.Name)
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
return sortedKeys(seen), nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -77,8 +124,11 @@ func parseGlobalSkillsList(lines []string) []string {
continue
}
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
if strings.HasPrefix(trimmed, "Agents:") {
continue
}
if isGlobalSkillsSectionHeader(trimmed) {
continue
}
@@ -91,21 +141,24 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0]
// Validate and add
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func isGlobalSkillsSectionHeader(line string) bool {
switch line {
case "General", "Project", "Local":
return true
default:
return false
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -131,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
if len(parts) > 0 {
candidate := parts[0]
// Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
@@ -194,7 +246,9 @@ func PlanSync(input SyncInput) SyncPlan {
}
type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
@@ -228,21 +282,15 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 1: List official skills ---
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
// --- Step 3: Read previous state ---
@@ -298,6 +346,58 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
indexResult := runner.ListOfficialSkillsIndex()
if indexResult == nil || indexResult.Err != nil {
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
} else {
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
if err != nil {
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
} else if len(official) > 0 {
return official, "", true
} else {
reasons = append(reasons, "official skills index contains no skills")
}
}
officialResult := runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
return nil, strings.Join(reasons, "; "), false
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) > 0 {
return official, "", true
}
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
} else {
reasons = append(reasons, "official skills list returned no skills")
}
return nil, strings.Join(reasons, "; "), false
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
textResult := runner.ListGlobalSkills()
if textResult != nil && textResult.Err == nil {
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
return nil, false
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
}
}
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
input := `Available Skills
│ lark-calendar
│ official-shared
│ bad/name
`
got := ParseSkillsList(input)
want := []string{"lark-calendar", "official-shared"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
@@ -67,6 +80,86 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
}
}
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
input := `Global Skills
General
lark-apps ~/.agents/skills/lark-apps
lark-base ~/.agents/skills/lark-base
`
got := ParseSkillsList(input)
want := []string{"lark-apps", "lark-base"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSON(t *testing.T) {
input := `[
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
{"name":""},
{"name":" "},
{"name":"bad skill"}
]`
got := ParseGlobalSkillsJSON(input)
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`{"name":"lark-calendar"}`,
`[]`,
} {
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
input := `{
"skills": [
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
{"name":" lark-base ","description":"Base","files":[]},
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
{"name":"","description":"empty","files":["SKILL.md"]}
]
}`
got, err := ParseOfficialSkillsIndexJSON(input)
if err != nil {
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
}
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
}
}
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`[{"name":"lark-calendar"}]`,
`{"name":"lark-calendar"}`,
`{"skills":[]}`,
`{"skills":[{"name":"bad skill"}]}`,
} {
got, err := ParseOfficialSkillsIndexJSON(input)
if err == nil && len(got) != 0 {
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -113,14 +206,22 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
officialIndexOut string
officialOut string
globalJSONOut string
globalOut string
officialIndexErr error
officialErr error
globalJSONErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
}
func officialSkillsOutput(names ...string) string {
@@ -134,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
return b.String()
}
func officialSkillsIndexOutput(names ...string) string {
var b strings.Builder
b.WriteString(`{"skills":[`)
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
}
b.WriteString(`]}`)
return b.String()
}
func globalSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Global Skills\n\n")
@@ -146,14 +260,45 @@ func globalSkillsOutput(names ...string) string {
return b.String()
}
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
f.listedIndex++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialIndexOut)
r.Err = f.officialIndexErr
return r
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
f.listedOfficial++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
@@ -186,8 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -199,6 +346,12 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState()
if err != nil || !readable {
@@ -213,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
}
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-should-not-be-used"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
if runner.listedIndex != 1 {
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
}
if runner.listedOfficial != 0 {
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
}
}
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
}
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
}
}
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -246,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -262,47 +523,76 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
}
}
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
}
@@ -318,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -343,10 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -374,10 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -406,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -423,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -447,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -472,10 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -496,10 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -519,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -537,8 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

@@ -15,7 +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{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -16,7 +16,16 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -18,7 +18,7 @@ import (
// forbidigo's errs-typed-only ban does not see them because they are method
// calls, not output.Err* identifiers — this AST rule covers that gap.
//
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
// Migrated code must call the domain's typed API wrapper or use
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors.
//
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
File: path,
Line: fset.Position(call.Pos()).Line,
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors",
})
}

View File

@@ -618,6 +618,35 @@ func boom() error {
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
for _, path := range []string{
"shortcuts/okr/okr_image_upload.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_update.go",
} {
t.Run(path, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
})
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
src := `package drive
@@ -662,7 +691,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -801,6 +830,26 @@ func boom(runtime *common.RuntimeContext) error {
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
src := `package task
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "CallAPI") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive
@@ -851,14 +900,14 @@ func boom(runtime *common.RuntimeContext) error {
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
@@ -894,32 +943,62 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"ResolveOpenIDs",
"HandleApiResult",
}
for _, helper := range helpers {
t.Run(helper, func(t *testing.T) {
src := `package drive
paths := []string{
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
}
for _, path := range paths {
for _, helper := range helpers {
t.Run(path+"_"+helper, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
}
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.RejectDangerousChars("--summary", "x")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
import "github.com/larksuite/cli/shortcuts/common"
@@ -927,7 +1006,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}

View File

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

View File

@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
},
}

View File

@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
},
}

View File

@@ -196,9 +196,7 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
},
})
args := []string{"+advperm-enable", "--base-token", "app_x"}
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
}
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
@@ -226,7 +224,5 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
},
})
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
}

View File

@@ -55,24 +55,24 @@ func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *c
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
return baseFlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return common.FlagErrorf("--type must not be blank")
return baseFlagErrorf("--type must not be blank")
}
return nil
}
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
return baseFlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
return baseFlagErrorf("--name must not be blank")
}
return nil
}

View File

@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber()
if err := dec.Decode(&dsl); err != nil {
return common.FlagErrorf("--dsl invalid JSON: %v", err)
return baseFlagErrorf("--dsl invalid JSON: %v", err)
}
_, hasDim := dsl["dimensions"]
_, hasMeas := dsl["measures"]
if !hasDim && !hasMeas {
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
}
return nil
},

View File

@@ -4,9 +4,13 @@
package base
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
)
@@ -24,76 +28,198 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
// structured ErrAPI, with server-provided message/hint promoted to the top level.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
return nil, baseAPIBoundaryError(err, action)
}
resultMap, _ := result.(map[string]interface{})
code, _ := util.ToFloat64(resultMap["code"])
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
}
if _, exists := resultMap["code"]; !exists {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
}
code, numeric := util.ToFloat64(resultMap["code"])
if !numeric {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
}
if code == 0 {
return resultMap["data"], nil
}
larkCode := int(code)
msg := extractDataErrorMessage(resultMap)
if strings.TrimSpace(msg) == "" {
msg, _ = resultMap["msg"].(string)
}
detail := extractErrorDetail(resultMap)
apiErr := output.ErrAPI(larkCode, msg, detail)
hint := extractErrorHint(resultMap)
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
apiErr.Detail.Hint = hint
}
if apiErr.Detail != nil {
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
}
return nil, apiErr
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
}
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
detailMap, ok := detail.(map[string]interface{})
if !ok {
return nil
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
// typed envelope and exists so call sites read as flag rejections.
func baseFlagErrorf(format string, args ...any) error {
return baseValidationErrorf(format, args...)
}
func baseValidationErrorf(format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
if params := flagParams(msg); len(params) > 0 {
err = err.WithParam(params[0].Name).WithParams(params...)
}
for key, value := range detailMap {
if value == nil {
delete(detailMap, key)
if cause := firstErrorArg(args); cause != nil {
err = err.WithCause(cause)
}
return err
}
func flagParams(msg string) []errs.InvalidParam {
reason := msg
seen := map[string]bool{}
params := []errs.InvalidParam{}
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
end := start + 2
for end < len(msg) {
ch := msg[end]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
end++
continue
}
break
}
if end > start+2 {
name := msg[start:end]
if !seen[name] {
seen[name] = true
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
}
msg = msg[end:]
}
if len(detailMap) == 0 {
return nil
}
return detailMap
return params
}
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
return detail
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := nonNilMapValue(data, "error"); ok {
return detail
func firstErrorArg(args []any) error {
for _, arg := range args {
if err, ok := arg.(error); ok {
return err
}
}
return nil
}
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
if src == nil {
return nil, false
// baseMissingFileIOError reports a broken runtime wiring: a command that needs
// local file access was constructed without a FileIO provider. The user cannot
// fix this by changing flags, so it classifies as internal, not validation.
func baseMissingFileIOError(format string, args ...any) error {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
}
func baseInputStatError(err error) error {
if err == nil {
return nil
}
value, ok := src[key]
if !ok {
return nil, false
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
}
switch value.(type) {
case nil:
return nil, false
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}
func baseSaveError(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
default:
return value, true
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
}
}
func baseAPIBoundaryError(err error, action string) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
}
func baseUploadAttachmentError(filePath string, err error) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
}
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
if resultMap == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
}
if msg := extractDataErrorMessage(resultMap); msg != "" {
resultMap["msg"] = msg
}
hint := extractErrorHint(resultMap)
if logID := extractBaseErrorLogID(resultMap); logID != "" {
resultMap["log_id"] = logID
}
err := errclass.BuildAPIError(resultMap, cc)
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok && hint != "" {
p.Hint = hint
}
return err
}
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
if _, ok := errs.ProblemOf(err); !ok {
return err
}
result, parseErr := decodeBaseV3Response(body)
if parseErr != nil {
return err
}
enriched := baseAPIErrorFromResult(result, cc)
if enriched == nil {
return err
}
src, _ := errs.ProblemOf(enriched)
dst, _ := errs.ProblemOf(err)
if src != nil && dst != nil {
dst.Message = src.Message
dst.Hint = src.Hint
// A body without log_id must not erase a header-derived LogID
// already carried by err.
if src.LogID != "" {
dst.LogID = src.LogID
}
}
return err
}
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
return ""
}
func extractErrorHint(resultMap map[string]interface{}) string {
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
if hint := consumeStringField(detail, "hint"); hint != "" {

View File

@@ -4,30 +4,15 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
)
func TestErrorDetailHelpers(t *testing.T) {
if value, ok := nonNilMapValue(nil, "error"); ok || value != nil {
t.Fatalf("nil map should not return value")
}
if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil {
t.Fatalf("nil entry should not return value")
}
detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil {
t.Fatalf("expected non-nil detail")
}
if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil {
t.Fatalf("expected root detail")
}
if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil {
t.Fatalf("expected nested detail")
}
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
t.Fatalf("hint=%q", got)
}
@@ -53,9 +38,12 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
t.Fatalf("err=%v", err)
} else {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
t.Fatalf("expected structured code 190001, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 190001 {
t.Fatalf("expected typed code 190001, got %T %v", err, err)
}
if p.Hint != "check field name" {
t.Fatalf("hint=%q", p.Hint)
}
}
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
@@ -63,7 +51,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
}
}
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
result := map[string]interface{}{
"code": 800010407,
"msg": "cell value invalid",
@@ -87,55 +75,27 @@ func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
errDetail := exitErr.Detail
if errDetail.Code != 800010407 {
t.Fatalf("code=%d", errDetail.Code)
if p.Code != 800010407 {
t.Fatalf("code=%d", p.Code)
}
if errDetail.Hint != "Provide a number value." {
t.Fatalf("hint=%q", errDetail.Hint)
if p.Message != "The cell value does not match the expected input shape." {
t.Fatalf("message=%q", p.Message)
}
detail, _ := errDetail.Detail.(map[string]interface{})
if detail == nil {
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
if p.Hint != "Provide a number value." {
t.Fatalf("hint=%q", p.Hint)
}
if _, exists := detail["message"]; exists {
t.Fatalf("detail should not repeat message: %#v", detail)
}
if _, exists := detail["hint"]; exists {
t.Fatalf("detail should not repeat hint: %#v", detail)
}
if _, exists := detail["docs_url"]; exists {
t.Fatalf("detail should omit nil docs_url: %#v", detail)
}
if detail["level"] != "error" {
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
}
if detail["extra_context"] != "future detail field" {
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
}
if detail["path"] != "Amount" || detail["value"] != "abc" {
t.Fatalf("cleaned detail mismatch: %#v", detail)
}
if detail["logid"] != "20260508160000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
}
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
t.Fatalf("retryable=%v", detail["retryable"])
}
table, _ := detail["table"].(map[string]interface{})
if table["id"] != "tbl_1" || table["name"] != "Orders" {
t.Fatalf("table=%#v", detail["table"])
if p.LogID != "20260508160000000000000000000000" {
t.Fatalf("logID=%q", p.LogID)
}
}
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
result := map[string]interface{}{
"code": output.LarkErrTokenNoPermission,
"code": 99991676,
"msg": "permission denied",
"data": map[string]interface{}{
"error": map[string]interface{}{
@@ -146,15 +106,15 @@ func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T)
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if exitErr.Detail.Message != "Permission denied [99991676]" {
t.Fatalf("message=%q", exitErr.Detail.Message)
if p.Code != 99991676 {
t.Fatalf("code=%d", p.Code)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
}
@@ -167,16 +127,91 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
attachBaseErrorLogID(result, "20260508170000000000000000000000")
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["logid"] != "20260508170000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
if p.LogID != "20260508170000000000000000000000" {
t.Fatalf("logID=%q", p.LogID)
}
}
func TestHandleBaseAPIResultRejectsNonNumericCode(t *testing.T) {
for _, code := range []interface{}{"oops", map[string]interface{}{}, nil} {
result := map[string]interface{}{"code": code, "msg": "weird envelope"}
_, err := handleBaseAPIResultAny(result, nil, "list tables")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("code=%#v: expected typed error, got %T %v", code, err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("code=%#v: category/subtype=%s/%s", code, p.Category, p.Subtype)
}
if !strings.Contains(p.Message, "list tables") {
t.Fatalf("code=%#v: message=%q", code, p.Message)
}
}
}
func TestEnrichBaseAPIErrorFromBodyLogIDMerge(t *testing.T) {
t.Run("body without log_id keeps header-derived LogID", func(t *testing.T) {
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
err := enrichBaseAPIErrorFromBody(outer, []byte(`{"code":190001,"msg":"boom"}`), errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if p.Message != "boom" {
t.Fatalf("message=%q", p.Message)
}
if p.LogID != "header-log-id" {
t.Fatalf("logID=%q, want header-log-id", p.LogID)
}
})
t.Run("body log_id overrides header-derived LogID", func(t *testing.T) {
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
body := `{"code":190001,"msg":"boom","data":{"error":{"logid":"body-log-id"}}}`
err := enrichBaseAPIErrorFromBody(outer, []byte(body), errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if p.LogID != "body-log-id" {
t.Fatalf("logID=%q, want body-log-id", p.LogID)
}
})
}
func TestBaseMissingFileIOErrorIsInternal(t *testing.T) {
p, ok := errs.ProblemOf(baseMissingFileIOError("file operations require a FileIO provider"))
if !ok {
t.Fatal("expected typed error")
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
}
type assertErr struct{}
func (assertErr) Error() string { return "network timeout" }
func assertProblemCode(t *testing.T, err error, code int, messageParts ...string) {
t.Helper()
if err == nil {
t.Fatalf("expected error with code %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Code != code {
t.Fatalf("code=%d, want %d; err=%v", p.Code, code, err)
}
for _, part := range messageParts {
if !strings.Contains(p.Message, part) {
t.Fatalf("message=%q missing %q", p.Message, part)
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -513,6 +514,65 @@ func TestBaseBlockExecuteShortcuts(t *testing.T) {
}
}
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tests := []struct {
name string
shortcut common.Shortcut
args []string
params []string
}{
{
name: "create blank name",
shortcut: BaseBaseBlockCreate,
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
params: []string{"--name"},
},
{
name: "move conflicting sibling anchors",
shortcut: BaseBaseBlockMove,
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
params: []string{"--before-id", "--after-id"},
},
{
name: "rename blank name",
shortcut: BaseBaseBlockRename,
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
params: []string{"--name"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != tt.params[0] {
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
}
if len(validationErr.Params) != len(tt.params) {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
for i, param := range tt.params {
if validationErr.Params[i].Name != param {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
if validationErr.Params[i].Reason == "" {
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
}
}
})
}
}
func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -871,10 +931,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
t.Run("list-http-404", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404,
Body: "404 page not found",
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404,
RawBody: []byte("404 page not found"),
Headers: map[string][]string{
"Content-Type": {"text/plain"},
},
@@ -2093,6 +2153,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
t.Fatalf("err=%v should name the offending file", err)
}
})
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
@@ -2262,6 +2325,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "../escape",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
t.Fatalf("err=%v", err)
}
})
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -2458,21 +2538,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x",
"--output", "downloads",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
if err == nil {
t.Fatalf("err=%v", err)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
var partialErr *output.PartialFailureError
if !errors.As(err, &partialErr) {
t.Fatalf("expected partial failure error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
downloaded, _ := detail["downloaded"].([]map[string]interface{})
failed, _ := detail["failed"].([]map[string]interface{})
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
}
if detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
if envelope["ok"] != false {
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
}
data, _ := envelope["data"].(map[string]interface{})
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("message=%q", msg)
}
downloaded, _ := data["downloaded"].([]interface{})
failed, _ := data["failed"].([]interface{})
if len(downloaded) != 1 || len(failed) != 1 {
t.Fatalf("data=%#v", data)
}
downloadedItem, _ := downloaded[0].(map[string]interface{})
failedItem, _ := failed[0].(map[string]interface{})
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
t.Fatalf("data=%#v", data)
}
if data["log_id"] != "202605270001" {
t.Fatalf("data=%#v, want log_id", data)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
data, err := baseV3Call(runtime, "POST",

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,7 +42,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
var questionIds []string
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err)
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err)
}
_, err := baseV3Call(runtime, "DELETE",

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
data, err := baseV3Call(runtime, "PATCH",

View File

@@ -14,7 +14,6 @@ import (
"golang.org/x/sync/errgroup"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -62,31 +61,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
attachments, hasAttachments := raw["attachments"]
if !hasAttachments && fields == nil {
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
}
if hasAttachments {
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
if runtime.Str("base-token") == "" {
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"")
}
attMap, ok := attachments.(map[string]interface{})
if !ok {
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
}
for fieldName, value := range attMap {
paths, ok := value.([]interface{})
if !ok {
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
return baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
for i, item := range paths {
if _, ok := item.(string); !ok {
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
}
}
if len(paths) == 0 {
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
}
}
}
@@ -111,21 +110,21 @@ func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}
if attachments, ok := raw["attachments"]; ok {
attObj, ok := attachments.(map[string]interface{})
if !ok {
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
return nil, nil, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
}
if len(attObj) > 0 {
attMap = make(map[string][]string, len(attObj))
for fieldName, value := range attObj {
paths, ok := value.([]interface{})
if !ok {
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
return nil, nil, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
filePaths := make([]string, 0, len(paths))
for _, item := range paths {
if s, ok := item.(string); ok {
filePaths = append(filePaths, s)
} else {
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
return nil, nil, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
}
}
if len(filePaths) > 0 {
@@ -195,33 +194,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)")
}
// Step 1: 收集所有唯一路径(跨字段去重)
allPaths := collectUniquePaths(attachmentMap)
if len(allPaths) == 0 {
return common.FlagErrorf("attachments in --json contains no valid file paths")
return baseFlagErrorf("attachments in --json contains no valid file paths")
}
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
sizeMap := make(map[string]int64, len(allPaths))
for _, filePath := range allPaths {
if _, err := validate.SafeInputPath(filePath); err != nil {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
}
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
}
if !fileInfo.Mode().IsRegular() {
return output.ErrValidation("attachment file %s is not a regular file", filePath)
return baseValidationErrorf("attachment file %s is not a regular file", filePath)
}
sizeMap[filePath] = fileInfo.Size()
}
@@ -328,7 +327,7 @@ func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, t
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
if err != nil {
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
return nil, baseUploadAttachmentError(filePath, err)
}
return att, nil
}

View File

@@ -5,9 +5,10 @@ package base
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/output"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -17,6 +18,14 @@ import (
// - Inner: business-level code/message inside the data object
//
// The data field may be a JSON object (actual behavior) or a JSON string (per doc).
func handleRoleAPIResponse(runtime *common.RuntimeContext, apiResp *larkcore.ApiResp, action string) error {
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
enriched := enrichBaseAPIErrorFromBody(err, apiResp.RawBody, runtime.APIClassifyContext())
return prefixRoleActionError(enriched, action)
}
return handleRoleResponse(runtime, apiResp.RawBody, action)
}
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
var resp struct {
Code int `json:"code"`
@@ -24,23 +33,17 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawBody, &resp); err != nil {
return fmt.Errorf("failed to parse response: %v", err)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err)
}
if resp.Code != 0 {
msg := resp.Msg
// When outer msg is empty, try to extract error details from data.error.message
if msg == "" && len(resp.Data) > 0 {
var errData struct {
Error struct {
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"error"`
}
if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" {
msg = errData.Error.Message
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg}
if len(resp.Data) > 0 {
var data interface{}
if json.Unmarshal(resp.Data, &data) == nil {
result["data"] = data
}
}
return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
return baseRoleAPIError(runtime, result, action)
}
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
@@ -75,7 +78,8 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
}
if codeInt != 0 {
msg, _ := m["message"].(string)
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
return baseRoleAPIError(runtime, result, action)
}
// code == 0, extract the inner data if present
if innerData, hasInner := m["data"]; hasInner {
@@ -98,3 +102,20 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
runtime.Out(data, nil)
return nil
}
func baseRoleAPIError(runtime *common.RuntimeContext, result map[string]interface{}, action string) error {
return prefixRoleActionError(baseAPIErrorFromResult(result, runtime.APIClassifyContext()), action)
}
// prefixRoleActionError prepends the failed role action ("create role failed",
// "get role failed", ...) to a typed error's message so both the classified
// outer-response path and the parsed-body path carry the same context.
func prefixRoleActionError(err error, action string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok && action != "" {
p.Message = action + ": " + p.Message
}
return err
}

View File

@@ -34,11 +34,11 @@ var BaseRoleCreate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
var body map[string]any
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
return common.FlagErrorf("--json must be valid JSON: %v", err)
return baseFlagErrorf("--json must be valid JSON: %v", err)
}
return nil
},
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "create role failed")
return handleRoleAPIResponse(runtime, apiResp, "create role failed")
},
}

View File

@@ -34,10 +34,10 @@ var BaseRoleDelete = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("role-id")) == "" {
return common.FlagErrorf("--role-id must not be blank")
return baseFlagErrorf("--role-id must not be blank")
}
return nil
},
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed")
return handleRoleAPIResponse(runtime, apiResp, "delete role failed")
},
}

View File

@@ -33,10 +33,10 @@ var BaseRoleGet = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("role-id")) == "" {
return common.FlagErrorf("--role-id must not be blank")
return baseFlagErrorf("--role-id must not be blank")
}
return nil
},
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "get role failed")
return handleRoleAPIResponse(runtime, apiResp, "get role failed")
},
}

View File

@@ -32,7 +32,7 @@ var BaseRoleList = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
return handleRoleAPIResponse(runtime, apiResp, "list roles failed")
},
}

View File

@@ -375,9 +375,7 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request")
}
func TestBaseRoleListExecuteTransportError(t *testing.T) {
@@ -405,9 +403,7 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-list", "--base-token", "app_x"}
if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found")
}
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
@@ -421,9 +417,7 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden")
}
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
@@ -437,9 +431,7 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params")
}
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
@@ -457,9 +449,7 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
},
})
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found")
}
// ---------------------------------------------------------------------------
@@ -487,9 +477,7 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("outer error code", func(t *testing.T) {
rt := newRoleResponseRuntime(t)
if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error")
})
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
@@ -574,9 +562,7 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("business code non-zero", func(t *testing.T) {
rt := newRoleResponseRuntime(t)
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied")
})
t.Run("data is array", func(t *testing.T) {

View File

@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("role-id")) == "" {
return common.FlagErrorf("--role-id must not be blank")
return baseFlagErrorf("--role-id must not be blank")
}
var body map[string]any
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
return common.FlagErrorf("--json must be valid JSON: %v", err)
return baseFlagErrorf("--json must be valid JSON: %v", err)
}
return nil
},
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "update role failed")
return handleRoleAPIResponse(runtime, apiResp, "update role failed")
},
}

View File

@@ -30,34 +30,34 @@ func baseTableID(runtime *common.RuntimeContext) string {
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", common.FlagErrorf("--%s cannot be empty", flagName)
return "", baseFlagErrorf("--%s cannot be empty", flagName)
}
if !strings.HasPrefix(raw, "@") {
return raw, nil
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" {
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName)
}
if pc.fio == nil {
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName)
}
f, err := pc.fio.Open(path)
if err != nil {
var pathErr *fileio.PathValidationError
if errors.As(err, &pathErr) {
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
return "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
}
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
}
content := strings.TrimSpace(string(data))
if content == "" {
return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path)
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path)
}
return content, nil
}
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
func formatJSONError(flagName string, target string, err error) error {
if syntaxErr, ok := err.(*json.SyntaxError); ok {
return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
}
if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
if typeErr.Field != "" {
return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
}
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
}
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
}
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
}
}
if len(active) == 0 {
return "", common.FlagErrorf("specify one action")
return "", baseFlagErrorf("specify one action")
}
if len(active) > 1 {
flags := make([]string, 0, len(active))
for _, item := range active {
flags = append(flags, "--"+item)
}
return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
}
return active[0], nil
}
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
for idx, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1)
return nil, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1)
}
items = append(items, obj)
}
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
case map[string]interface{}, []interface{}:
return value, nil
default:
return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
return nil, baseFlagErrorf("--%s must be a JSON object or array", flagName)
}
}

View File

@@ -6,9 +6,9 @@ package base
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
// text 类型必须提供 data-config含 text 内容)
if strings.ToLower(runtime.Str("type")) == "text" {
return fmt.Errorf("text 类型组件必须提供 data-config包含必填字段 text")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config包含必填字段 text").WithParam("--data-config")
}
return nil
}

View File

@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
if fieldType == "lookup" {
guidePath = "skills/lark-base/references/lookup-field-guide.md"
}
return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
}
return nil
}

View File

@@ -17,6 +17,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -41,10 +42,10 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err)
}
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
if result == nil {
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
return result, nil
}
@@ -152,7 +153,7 @@ func cloneValue(value interface{}) interface{} {
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
trimmed := strings.TrimSpace(typeName)
if trimmed == "" {
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
}
switch strings.ToLower(trimmed) {
case "text", "phone", "url", "email", "barcode":
@@ -192,7 +193,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
case "modifiedtime", "modified_time", "modified-time":
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
default:
return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
}
}
@@ -252,10 +253,10 @@ func normalizeSelectOptions(raw interface{}) []interface{} {
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
if isPrimary {
return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
}
if isHidden {
return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
}
spec, err := resolveFieldTypeSpec(typeName)
if err != nil {
@@ -354,7 +355,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
if rawFields != "" {
var fields []interface{}
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
}
return fields, nil
}
@@ -366,7 +367,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
for _, spec := range specs {
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
if err != nil {
return nil, fmt.Errorf("field %q: %w", spec.Name, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
}
fields = append(fields, body)
}
@@ -410,20 +411,15 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
h.Set("X-App-Id", runtime.Config.AppID)
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
if err != nil {
return nil, err
return nil, baseAPIBoundaryError(err, "API call failed")
}
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
return nil, statusErr
}
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
}
result, parseErr := decodeBaseV3Response(resp.RawBody)
if parseErr == nil && baseV3ResultCode(result) != 0 {
attachBaseErrorLogID(result, baseResponseLogID(resp))
return result, nil
}
if resp.StatusCode >= http.StatusBadRequest {
body := strings.TrimSpace(string(resp.RawBody))
if body == "" {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
if parseErr != nil {
return nil, parseErr
}
@@ -435,18 +431,14 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %w", err)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
}
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
}
return result, nil
}
func baseV3ResultCode(result map[string]interface{}) int {
if result == nil {
return 0
}
return toInt(result["code"])
}
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
if result == nil || strings.TrimSpace(logID) == "" {
return
@@ -480,6 +472,33 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
}
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
if resp == nil || resp.StatusCode < http.StatusBadRequest {
return nil
}
p, ok := errs.ProblemOf(classified)
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
return nil
}
body := strings.TrimSpace(string(resp.RawBody))
if resp.StatusCode >= http.StatusInternalServerError {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResult(result, err, "API call failed")
@@ -525,7 +544,7 @@ func toStringSlice(v interface{}) []string {
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, fmt.Errorf("limit must be greater than 0")
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
@@ -555,7 +574,7 @@ func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, lim
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, fmt.Errorf("limit must be greater than 0")
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
@@ -577,7 +596,7 @@ func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, of
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, fmt.Errorf("limit must be greater than 0")
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
@@ -603,7 +622,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
return field, nil
}
}
return nil, fmt.Errorf("field %q not found", ref)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
}
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
@@ -612,7 +631,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
return table, nil
}
}
return nil, fmt.Errorf("table %q not found", ref)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
}
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
@@ -621,7 +640,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
return view, nil
}
}
return nil, fmt.Errorf("view %q not found", ref)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
}
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
@@ -738,18 +757,18 @@ func canonicalValue(v interface{}) string {
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
var tuples []interface{}
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
return nil, fmt.Errorf("--%s invalid JSON array", flagName)
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
}
result := make([]namedTypeSpec, 0, len(tuples))
for idx, item := range tuples {
pair, ok := item.([]interface{})
if !ok || len(pair) != 2 {
return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1)
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
}
name, ok1 := pair[0].(string)
typeName, ok2 := pair[1].(string)
if !ok1 || !ok2 {
return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
}
result = append(result, namedTypeSpec{Name: name, Type: typeName})
}
@@ -1155,9 +1174,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
return errs
}
func formatDataConfigErrors(errs []string) error {
if len(errs) == 0 {
func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
return nil
}
return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -19,7 +20,7 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
case "", "json", "markdown":
return nil
default:
return output.ErrValidation("--format must be json or markdown")
return baseValidationErrorf("--format must be json or markdown")
}
}
@@ -33,7 +34,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
runtime.Out(data, nil)
return nil
}
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
}
rendered, err := renderer(data)
if err != nil {
@@ -43,7 +44,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
return baseContentSafetyBlockError(scanResult)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
@@ -52,6 +53,20 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
return nil
}
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
message := "content safety violation detected"
var rules []string
if scanResult.Alert != nil {
rules = scanResult.Alert.MatchedRules
}
if len(rules) > 0 {
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
}
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
WithRules(rules...).
WithCause(scanResult.BlockErr)
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
}
@@ -61,7 +76,7 @@ func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
}
if len(recordIDs) == 1 && len(rows) == 1 {
rowItems, _ := rows[0].([]interface{})
@@ -78,7 +93,7 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
}
var b strings.Builder

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -212,9 +213,12 @@ func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T)
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
t.Fatalf("err=%v, want content safety exit error", err)
var csErr *errs.ContentSafetyError
if !errors.As(err, &csErr) {
t.Fatalf("err=%v, want typed content safety error", err)
}
if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" {
t.Fatalf("rules=%v", csErr.Rules)
}
if stdout.Len() > 0 {
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())

View File

@@ -49,7 +49,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
}
if jsonRaw != "" {
pc := newParseCtx(runtime)
@@ -59,11 +59,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
}
recordIDListValue, ok := body["record_id_list"]
if !ok {
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
return recordSelection{}, baseFlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
}
recordIDItems, ok := recordIDListValue.([]interface{})
if !ok {
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
return recordSelection{}, baseFlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordIDs(recordIDItems)
if err != nil {
@@ -117,14 +117,14 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
return nil, baseFlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
}
if len(items) == 0 {
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
return nil, baseFlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
@@ -152,7 +152,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
if opts.allowNil {
return nil, nil
}
return nil, common.FlagErrorf(opts.typeError)
return nil, baseFlagErrorf(opts.typeError)
case []interface{}:
rawItems = typed
case []string:
@@ -161,30 +161,30 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
rawItems = append(rawItems, item)
}
default:
return nil, common.FlagErrorf(opts.typeError)
return nil, baseFlagErrorf(opts.typeError)
}
if len(rawItems) == 0 {
if opts.allowEmpty {
return nil, nil
}
return nil, common.FlagErrorf(opts.emptyError)
return nil, baseFlagErrorf(opts.emptyError)
}
if opts.max > 0 && len(rawItems) > opts.max {
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
}
seen := make(map[string]int, len(rawItems))
result := make([]string, 0, len(rawItems))
for index, value := range rawItems {
item, ok := value.(string)
if !ok {
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
return nil, baseFlagErrorf("%s %d must be a string", opts.itemName, index+1)
}
item = strings.TrimSpace(item)
if item == "" {
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
return nil, baseFlagErrorf("%s %d must not be empty", opts.itemName, index+1)
}
if first, exists := seen[item]; exists {
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
return nil, baseFlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
}
seen[item] = index + 1
result = append(result, item)
@@ -332,10 +332,10 @@ const maxShareBatchSize = 100
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
recordIDs := deduplicateRecordIDs(runtime)
if len(recordIDs) == 0 {
return common.FlagErrorf("--record-ids is required and must not be empty")
return baseFlagErrorf("--record-ids is required and must not be empty")
}
if len(recordIDs) > maxShareBatchSize {
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
}
return nil
}

View File

@@ -71,18 +71,18 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
} else if obj, ok := value.(map[string]interface{}); ok {
rawSortConfig, ok := obj["sort_config"]
if !ok {
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
parsed, ok := rawSortConfig.([]interface{})
if !ok {
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label)
}
sortConfig = parsed
} else {
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
if len(sortConfig) > recordSortMaxCount {
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
}
return sortConfig, nil
}
@@ -90,7 +90,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err)
}
return string(data), nil
}
@@ -220,16 +220,16 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
}
_, err := recordSearchJSONBody(runtime)
return err
}
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return common.FlagErrorf("--keyword is required unless --json is used")
return baseFlagErrorf("--keyword is required unless --json is used")
}
if len(runtime.StrArray("search-field")) == 0 {
return common.FlagErrorf("--search-field is required unless --json is used")
return baseFlagErrorf("--search-field is required unless --json is used")
}
return validateRecordQueryOptions(runtime)
}

View File

@@ -22,7 +22,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -225,7 +224,7 @@ func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeCont
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
if runtime.Changed("name") {
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
return baseFlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
}
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
if err != nil {
@@ -245,9 +244,16 @@ func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
return err
}
if len(tokens) != 1 {
const outputDirRequired = "--output must be an existing directory when downloading multiple attachments or when --file-token is omitted"
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
if statErr != nil || !info.IsDir() {
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
if statErr != nil {
if errors.Is(statErr, fileio.ErrPathValidation) {
return baseValidationErrorf("unsafe output path: %s", statErr)
}
return baseFlagErrorf(outputDirRequired)
}
if !info.IsDir() {
return baseFlagErrorf(outputDirRequired)
}
}
return nil
@@ -269,7 +275,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
return err
}
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
}
resolvedFieldID := fieldID(field)
if resolvedFieldID == "" {
@@ -316,7 +322,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
return err
}
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
}
resolvedFieldID := fieldID(field)
if resolvedFieldID == "" {
@@ -353,7 +359,7 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
if err != nil {
failed := attachmentDownloadFailure(target, err)
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
return attachmentDownloadProgressError(runtime, err, downloaded, []map[string]interface{}{failed})
}
downloaded = append(downloaded, saved)
}
@@ -364,20 +370,20 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
fio := runtime.FileIO()
if fio == nil {
return nil, output.ErrValidation("file operations require a FileIO provider")
return nil, baseValidationErrorf("file operations require a FileIO provider")
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return nil, output.ErrValidation("unsafe file path: %s", err)
return nil, baseValidationErrorf("unsafe file path: %s", err)
}
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
return nil, baseValidationErrorf("file not accessible: %s: %v", filePath, err)
}
if fileInfo.IsDir() {
return nil, output.ErrValidation("file path is a directory: %s", filePath)
return nil, baseValidationErrorf("file path is a directory: %s", filePath)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
return nil, baseValidationErrorf("file %s exceeds 2GB limit (size: %s)", filePath, common.FormatSize(fileInfo.Size()))
}
return fileInfo, nil
}
@@ -412,13 +418,13 @@ func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, e
for index, token := range tokens {
token = strings.TrimSpace(token)
if token == "" {
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
return nil, baseFlagErrorf("attachment file token %d must not be empty", index+1)
}
normalized = append(normalized, token)
}
normalized = dedupeStringsPreserveOrder(normalized)
if len(normalized) > baseAttachmentMaxBatchSize {
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
return nil, baseFlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
}
return normalized, nil
}
@@ -453,10 +459,10 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
if len(recordIDs) == 0 {
return nil, output.ErrValidation("provide at least one record id")
return nil, baseValidationErrorf("provide at least one record id")
}
if len(recordIDs) > baseAttachmentGetMaxRecords {
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
return nil, baseValidationErrorf("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
"record_id_list": recordIDs,
@@ -560,14 +566,14 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
f, err := fio.Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", baseInputStatError(err)
}
defer f.Close()
buf := make([]byte, 512)
n, readErr := f.Read(buf)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return "", output.ErrValidation("cannot read file: %s", readErr)
return "", baseValidationErrorf("cannot read file: %s", readErr)
}
return detectAttachmentMIMEFromContent(buf[:n]), nil
}
@@ -617,11 +623,11 @@ type baseAttachmentDownloadTarget struct {
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
recordRaw, ok := attachments[recordID]
if !ok {
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
return nil, baseValidationErrorf("record %q has no attachment metadata; verify the record-id", recordID)
}
fields, ok := recordRaw.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
return nil, baseValidationErrorf("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
}
byToken := map[string]baseAttachmentDownloadItem{}
fieldIDs := make([]string, 0, len(fields))
@@ -633,12 +639,12 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
rawList := fields[currentFieldID]
items, ok := rawList.([]interface{})
if !ok {
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
return nil, baseValidationErrorf("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
}
for _, rawItem := range items {
item, ok := rawItem.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
return nil, baseValidationErrorf("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
}
fileToken, _ := item["file_token"].(string)
if fileToken == "" {
@@ -668,7 +674,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
result = append(result, item)
}
if len(result) == 0 {
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
return nil, baseValidationErrorf("record %q has no attachments to download", recordID)
}
sort.SliceStable(result, func(i, j int) bool {
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
@@ -683,7 +689,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
for _, token := range tokens {
item, ok := byToken[token]
if !ok {
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
return nil, baseValidationErrorf("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
}
result = append(result, item)
}
@@ -702,15 +708,15 @@ func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseA
}
resolved, err := runtime.ResolveSavePath(targetPath)
if err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
return nil, baseValidationErrorf("unsafe output path: %s", err)
}
if previous, exists := seen[resolved]; exists {
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
return nil, baseValidationErrorf("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
}
seen[resolved] = item
if !overwrite {
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
}
}
targets = append(targets, baseAttachmentDownloadTarget{
@@ -776,7 +782,7 @@ func safeAttachmentFileTokenSuffix(fileToken string) string {
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
return nil, baseValidationErrorf("unsafe output path: %s", err)
}
query := larkcore.QueryParams{}
@@ -795,7 +801,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
if !overwrite {
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
}
}
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
@@ -803,7 +809,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, common.WrapSaveErrorByCategory(err, "io")
return nil, baseSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(targetPath)
if savedPath == "" {
@@ -822,7 +828,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
}
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
return map[string]interface{}{
failure := map[string]interface{}{
"record_id": target.Item.RecordID,
"field_id": target.Item.FieldID,
"file_token": target.Item.FileToken,
@@ -831,72 +837,45 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m
"resolved_path": target.ResolvedPath,
"error": err.Error(),
}
if p, ok := errs.ProblemOf(err); ok {
failure["type"] = string(p.Category)
failure["subtype"] = string(p.Subtype)
if p.Code != 0 {
failure["code"] = p.Code
}
if p.LogID != "" {
failure["log_id"] = p.LogID
}
}
return failure
}
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
func attachmentDownloadProgressError(runtime *common.RuntimeContext, err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
detail := map[string]interface{}{
payload := map[string]interface{}{
"message": msg,
"downloaded": downloaded,
"failed": failed,
}
const hint = "Some files may already have been saved. Inspect downloaded before retrying, or rerun with --overwrite if the failed target now exists."
payload["hint"] = hint
if p, ok := errs.ProblemOf(err); ok {
payload["type"] = string(p.Category)
payload["subtype"] = string(p.Subtype)
if p.Code != 0 {
payload["code"] = p.Code
}
}
if logID := baseAttachmentDownloadLogID(err); logID != "" {
detail["log_id"] = logID
}
const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists."
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: msg,
Hint: hint,
Detail: detail,
},
Err: err,
}
}
var netErr *errs.NetworkError
if errors.As(err, &netErr) {
return &output.ExitError{
Code: output.ExitNetwork,
Detail: &output.ErrDetail{
Type: "network",
Code: netErr.Code,
Message: msg,
Hint: hint,
Detail: detail,
},
Err: err,
}
}
return &output.ExitError{
Code: output.ExitInternal,
Detail: &output.ErrDetail{
Type: "io",
Message: msg,
Hint: hint,
Detail: detail,
},
Err: err,
payload["log_id"] = logID
}
return runtime.OutPartialFailure(payload, nil)
}
func baseAttachmentDownloadLogID(err error) string {
var netErr *errs.NetworkError
if errors.As(err, &netErr) {
if id := strings.TrimSpace(netErr.LogID); id != "" {
return id
}
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok {
if logID, _ := detail["log_id"].(string); logID != "" {
return strings.TrimSpace(logID)
}
if p, ok := errs.ProblemOf(err); ok {
if logID := strings.TrimSpace(p.LogID); logID != "" {
return logID
}
}
return ""

View File

@@ -5,7 +5,6 @@ package base
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -117,7 +116,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
for idx, item := range fieldItems {
body, ok := item.(map[string]interface{})
if !ok {
return fmt.Errorf("--fields item %d must be an object", idx+1)
return baseValidationErrorf("--fields item %d must be an object", idx+1)
}
if idx == 0 && len(defaultFields) > 0 {
fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body)

View File

@@ -31,7 +31,7 @@ var BaseWorkflowCreate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
pc := newParseCtx(runtime)
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")

View File

@@ -27,10 +27,10 @@ var BaseWorkflowDisable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
return nil
},

View File

@@ -28,10 +28,10 @@ var BaseWorkflowEnable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
return nil
},

View File

@@ -30,10 +30,10 @@ var BaseWorkflowGet = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
return nil
},

View File

@@ -28,7 +28,7 @@ var BaseWorkflowList = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},

View File

@@ -33,10 +33,10 @@ var BaseWorkflowUpdate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
pc := newParseCtx(runtime)
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {

View File

@@ -12,8 +12,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -29,7 +29,7 @@ const (
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
if depth > 10 {
return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "too many splits for instance_view")
}
if startTime > endTime {
return nil, nil
@@ -48,68 +48,67 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
return append(left, right...), nil
}
result, err := runtime.RawAPI("GET",
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)),
map[string]interface{}{
"start_time": fmt.Sprintf("%d", startTime),
"end_time": fmt.Sprintf("%d", endTime),
}, nil)
err = wrapPredefinedError(err)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
}
resultMap, _ := result.(map[string]interface{})
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
data, _ := resultMap["data"].(map[string]interface{})
items, _ := data["items"].([]interface{})
var events []map[string]interface{}
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
events = append(events, m)
// CallAPITyped returns a typed error for any non-zero API code. The two
// calendar instance_view limits (193103 time-range, 193104 too-many) are
// recoverable by narrowing the window, so inspect the typed code and
// recurse instead of treating them as fatal. Any other code falls through
// to return the typed error unchanged.
p, ok := errs.ProblemOf(err)
if !ok {
return nil, err
}
switch p.Code {
case larkErrCalendarTimeRangeExceeded:
mid := startTime + span/2
if mid <= startTime {
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
"query failed: time range exceeds 40-day limit, please narrow the range").
WithCode(larkErrCalendarTimeRangeExceeded)
}
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
case larkErrCalendarTooManyInstances:
if span <= minSplitWindowSeconds {
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
"query failed: more than 1000 instances in the time range, please narrow the range").
WithCode(larkErrCalendarTooManyInstances)
}
mid := startTime + span/2
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
default:
return nil, err
}
return events, nil
}
// Error 193103: time range exceeds limit -> split
if int(code) == larkErrCalendarTimeRangeExceeded {
mid := startTime + span/2
if mid <= startTime {
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range")
items, _ := data["items"].([]interface{})
var events []map[string]interface{}
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
events = append(events, m)
}
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
if err != nil {
return nil, err
}
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
if err != nil {
return nil, err
}
return append(left, right...), nil
}
return events, nil
}
// Error 193104: too many instances -> split
if int(code) == larkErrCalendarTooManyInstances {
if span <= minSplitWindowSeconds {
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range")
}
mid := startTime + span/2
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
if err != nil {
return nil, err
}
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
if err != nil {
return nil, err
}
return append(left, right...), nil
// fetchInstanceViewSplit halves [startTime, endTime] at mid and concatenates the
// results of the two recursive sub-range queries. Shared by the 193103/193104
// split paths.
func fetchInstanceViewSplit(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, mid, endTime int64, depth int) ([]map[string]interface{}, error) {
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
if err != nil {
return nil, err
}
msg, _ := resultMap["msg"].(string)
return nil, output.ErrAPI(int(code), msg, resultMap["error"])
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
if err != nil {
return nil, err
}
return append(left, right...), nil
}
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
@@ -147,20 +146,20 @@ func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) {
startTime, err := common.ParseTime(startInput)
if err != nil {
return 0, 0, output.ErrValidation("--start: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTime, err := common.ParseTime(endInput, "end")
if err != nil {
return 0, 0, output.ErrValidation("--end: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
startInt, err := strconv.ParseInt(startTime, 10, 64)
if err != nil {
return 0, 0, output.ErrValidation("invalid start time: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
endInt, err := strconv.ParseInt(endTime, 10, 64)
if err != nil {
return 0, 0, output.ErrValidation("invalid end time: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
return startInt, endInt, nil

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -60,7 +61,7 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
case strings.HasPrefix(id, "ou_"):
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
default:
return nil, fmt.Errorf("unsupported attendee id format: %s", id)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported attendee id format: %s", id)
}
}
return attendees, nil
@@ -89,8 +90,8 @@ var CalendarCreate = common.Shortcut{
}
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
@@ -102,35 +103,35 @@ var CalendarCreate = common.Shortcut{
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--attendee-ids")
}
}
}
if runtime.Str("start") == "" {
return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --start (e.g. '2026-03-12T14:00+08:00')").WithParam("--start")
}
if runtime.Str("end") == "" {
return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end")
}
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return common.FlagErrorf("--start: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return common.FlagErrorf("--end: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
s, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return common.FlagErrorf("invalid start time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
e, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return common.FlagErrorf("invalid end time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
if e <= s {
return common.FlagErrorf("end time must be after start time")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
}
return nil
},
@@ -183,27 +184,26 @@ var CalendarCreate = common.Shortcut{
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return output.ErrValidation("--start: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return output.ErrValidation("--end: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
eventData := buildEventData(runtime, startTs, endTs)
// Create event
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
nil, eventData)
err = wrapPredefinedError(err)
if err != nil {
return err
}
event, _ := data["event"].(map[string]interface{})
eventId, _ := event["event_id"].(string)
if eventId == "" {
return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create event: no event_id returned")
}
// Add attendees if specified
@@ -214,27 +214,25 @@ var CalendarCreate = common.Shortcut{
}
attendees, err := parseAttendees(attendeesStr, currentUserId)
if err != nil {
return output.ErrValidation("invalid attendee id: %v", err)
return withParam(err, "--attendee-ids")
}
_, err = runtime.CallAPI("POST",
_, err = runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{
"attendees": attendees,
"need_notification": true,
})
err = wrapPredefinedError(err)
if err != nil {
// Rollback: delete the event
_, rollbackErr := runtime.RawAPI("DELETE",
_, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
map[string]interface{}{"need_notification": false}, nil)
rollbackErr = wrapPredefinedError(rollbackErr)
if rollbackErr != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
return withStepContext(err, "rollback also failed (%v); orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
}
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err)
return withStepContext(err, "event rolled back successfully")
}
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -20,20 +21,20 @@ func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, err
startTs, err := common.ParseTime(startInput)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(endInput, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
startSec, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
endSec, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
timeMin := time.Unix(startSec, 0).Format(time.RFC3339)
@@ -73,13 +74,13 @@ var CalendarFreebusy = common.Shortcut{
}
userId := runtime.Str("user-id")
if userId == "" && runtime.IsBot() {
return common.FlagErrorf("--user-id is required for bot identity")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required for bot identity").WithParam("--user-id")
}
if userId == "" && runtime.UserOpenId() == "" {
return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot determine user ID, specify --user-id or ensure you are logged in").WithParam("--user-id")
}
if userId != "" {
if _, err := common.ValidateUserID(userId); err != nil {
if _, err := common.ValidateUserIDTyped("--user-id", userId); err != nil {
return err
}
}
@@ -93,16 +94,17 @@ var CalendarFreebusy = common.Shortcut{
timeMin, timeMax, err := parseFreebusyTimeRange(runtime)
if err != nil {
return output.ErrValidation("--start/--end: %v", err)
// parseFreebusyTimeRange already returns a typed *errs.ValidationError
// carrying the offending flag in .Param; pass it through unchanged.
return err
}
data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
"time_min": timeMin,
"time_max": timeMax,
"user_id": userId,
"need_rsvp_status": true,
})
err = wrapPredefinedError(err)
if err != nil {
return err
}

View File

@@ -8,13 +8,13 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -126,40 +126,40 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
rawSlots := runtime.StrArray(flagSlot)
if len(rawSlots) == 0 {
return nil, output.ErrValidation("specify at least one --slot")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one --slot").WithParam("--slot")
}
slots := make([]roomFindSlot, 0, len(rawSlots))
for _, raw := range rawSlots {
parts := strings.Split(strings.TrimSpace(raw), "~")
if len(parts) != 2 {
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --slot format %q, expected start~end", raw).WithParam("--slot")
}
startTs, err := common.ParseTime(parts[0])
if err != nil {
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start time %q: %v", parts[0], err).WithParam("--slot")
}
endTs, err := common.ParseTime(parts[1])
if err != nil {
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end time %q: %v", parts[1], err).WithParam("--slot")
}
startSec, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
}
endSec, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
}
if endSec <= startSec {
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slot end time must be after start time: %q", raw).WithParam("--slot")
}
startRFC3339, err := unixStringToRFC3339(startTs)
if err != nil {
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
}
endRFC3339, err := unixStringToRFC3339(endTs)
if err != nil {
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
}
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
}
@@ -196,7 +196,7 @@ func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string
seenChats[id] = true
}
default:
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
}
}
if currentUserID != "" && !seenUsers[currentUserID] {
@@ -249,20 +249,19 @@ func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*room
Body: req,
})
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.WrapInternal(err)
}
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return nil, err
}
var resp = &OpenAPIResponse[*roomFindData]{}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
}
if resp.Code != 0 {
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
}
if resp.Data != nil {
@@ -317,8 +316,8 @@ var CalendarRoomFind = common.Shortcut{
}
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
@@ -327,8 +326,8 @@ var CalendarRoomFind = common.Shortcut{
if name == "" {
continue
}
if err := common.RejectDangerousChars("--"+flagRoomName, name); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flagRoomName, name); err != nil {
return err
}
}
if _, err := parseRoomFindSlots(runtime); err != nil {
@@ -338,13 +337,13 @@ var CalendarRoomFind = common.Shortcut{
return err
}
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
return output.ErrValidation("--min-capacity must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be >= 0").WithParam("--min-capacity")
}
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
return output.ErrValidation("--max-capacity must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-capacity must be >= 0").WithParam("--max-capacity")
}
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
return output.ErrValidation("--min-capacity must be <= --max-capacity")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be <= --max-capacity").WithParam("--min-capacity")
}
return nil
},

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -51,15 +51,15 @@ var CalendarRsvp = common.Shortcut{
}
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
if eventId == "" {
return output.ErrValidation("event-id cannot be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
}
return nil
},
@@ -71,7 +71,7 @@ var CalendarRsvp = common.Shortcut{
eventId := strings.TrimSpace(runtime.Str("event-id"))
status := strings.TrimSpace(runtime.Str("rsvp-status"))
_, err := runtime.DoAPIJSON("POST",
_, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
validate.EncodePathSegment(calendarId),
validate.EncodePathSegment(eventId)),

View File

@@ -8,13 +8,13 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -70,11 +70,11 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
timeMin, err := common.ParseTime(startInput)
if err != nil {
return nil, output.ErrValidation("invalid --start: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --start: %v", err).WithParam("--start")
}
minSec, err := strconv.ParseInt(timeMin, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid start timestamp: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
startTime := time.Unix(minSec, 0)
@@ -87,12 +87,12 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
timeMax, err := common.ParseTime(endInput, "end")
if err != nil {
return nil, output.ErrValidation("invalid --end: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --end: %v", err).WithParam("--end")
}
// Convert Unix timestamp string back to RFC3339 since the API requires RFC3339
maxSec, err := strconv.ParseInt(timeMax, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid end timestamp: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
req.SearchStartTime = startTime.Format(time.RFC3339)
req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339)
@@ -157,23 +157,23 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
}
parts := strings.Split(r, "~")
if len(parts) != 2 {
return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --exclude format %q, expected 'start~end'", r).WithParam("--exclude")
}
startTsStr, err := common.ParseTime(parts[0])
if err != nil {
return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
}
endTsStr, err := common.ParseTime(parts[1], "end")
if err != nil {
return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
}
startSec, err := strconv.ParseInt(startTsStr, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp in --exclude: %v", err).WithParam("--exclude")
}
endSec, err := strconv.ParseInt(endTsStr, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp in --exclude: %v", err).WithParam("--exclude")
}
excludedTimes = append(excludedTimes, &EventTime{
EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339),
@@ -219,13 +219,13 @@ var CalendarSuggestion = common.Shortcut{
}
durationMinutes := runtime.Int(flagDurationMinutes)
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440").WithParam("--duration-minutes")
}
for _, flag := range []string{flagEventRrule, flagTimezone} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
@@ -237,7 +237,7 @@ var CalendarSuggestion = common.Shortcut{
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") {
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
}
}
}
@@ -245,14 +245,14 @@ var CalendarSuggestion = common.Shortcut{
startInput := runtime.Str(flagStart)
if startInput != "" {
if _, err := common.ParseTime(startInput); err != nil {
return output.ErrValidation("invalid start time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
}
endInput := runtime.Str(flagEnd)
if endInput != "" {
if _, err := common.ParseTime(endInput, "end"); err != nil {
return output.ErrValidation("invalid end time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
}
@@ -267,13 +267,13 @@ var CalendarSuggestion = common.Shortcut{
}
parts := strings.Split(r, "~")
if len(parts) != 2 {
return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid range format in --exclude: %q, expect start~end", r).WithParam("--exclude")
}
if _, err := common.ParseTime(parts[0]); err != nil {
return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
}
if _, err := common.ParseTime(parts[1], "end"); err != nil {
return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
}
}
}
@@ -292,20 +292,19 @@ var CalendarSuggestion = common.Shortcut{
Body: req,
})
if err != nil {
return err
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.WrapInternal(err)
}
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return err
}
var resp = &OpenAPIResponse[*SuggestionResponse]{}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
}
if resp.Code != 0 {
return output.ErrAPI(resp.Code, resp.Msg, resp.Data)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
}
data := resp.Data

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -53,14 +54,14 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
}
for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
if strings.TrimSpace(runtime.Str("event-id")) == "" {
return common.FlagErrorf("specify --event-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if _, _, err := buildCalendarUpdateEventData(runtime); err != nil {
return err
@@ -69,7 +70,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
return err
}
if !hasCalendarUpdateOperation(runtime) {
return common.FlagErrorf("nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
}
return nil
}
@@ -77,11 +78,11 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids"))
if err != nil {
return err
return withParam(err, "--add-attendee-ids")
}
removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids"))
if err != nil {
return err
return withParam(err, "--remove-attendee-ids")
}
removeSet := make(map[string]struct{}, len(removeIDs))
for _, id := range removeIDs {
@@ -89,7 +90,7 @@ func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
}
for _, id := range addIDs {
if _, ok := removeSet[id]; ok {
return output.ErrValidation("attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
}
}
return nil
@@ -124,27 +125,27 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
startChanged := runtime.Cmd.Flags().Changed("start")
endChanged := runtime.Cmd.Flags().Changed("end")
if startChanged != endChanged {
return nil, false, common.FlagErrorf("--start and --end must be specified together when updating event time")
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start and --end must be specified together when updating event time")
}
if startChanged {
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return nil, false, common.FlagErrorf("--start: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return nil, false, common.FlagErrorf("--end: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
s, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return nil, false, common.FlagErrorf("invalid start time: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
e, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return nil, false, common.FlagErrorf("invalid end time: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
if e <= s {
return nil, false, common.FlagErrorf("end time must be after start time")
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
}
body["start_time"] = map[string]string{"timestamp": startTs}
body["end_time"] = map[string]string{"timestamp": endTs}
@@ -169,7 +170,7 @@ func parseCalendarAttendeeIDs(attendeesStr string) ([]string, error) {
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
}
if _, ok := seen[id]; ok {
continue
@@ -195,7 +196,7 @@ func attendeeDeleteIDs(attendeesStr string) ([]map[string]string, error) {
case strings.HasPrefix(id, "ou_"):
deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id})
default:
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--remove-attendee-ids")
}
}
return deleteIDs, nil
@@ -280,7 +281,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
calendarID, eventID := calendarUpdateIDs(runtime)
if eventID == "" {
return output.ErrValidation("specify --event-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
@@ -291,10 +292,9 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
completed := []string{}
event := map[string]interface{}{}
if hasEventFields {
data, err := runtime.CallAPI("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
err = wrapPredefinedError(err)
data, err := runtime.CallAPITyped("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to update event %s: %v", eventID, err)
return withStepContext(err, "failed to update event %s after completed steps %v", eventID, completed)
}
if v, _ := data["event"].(map[string]interface{}); v != nil {
event = v
@@ -308,12 +308,11 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
if err != nil {
return err
}
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to remove attendees from event %s after completed steps %v: %v", eventID, completed, err)
return withStepContext(err, "failed to remove attendees from event %s after completed steps %v", eventID, completed)
}
removedCount = len(deleteIDs)
completed = append(completed, "remove_attendees")
@@ -323,14 +322,13 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
attendees, err := parseAttendees(addStr, "")
if err != nil {
return output.ErrValidation("invalid attendee id: %v", err)
return withParam(err, "--add-attendee-ids")
}
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID),
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees to event %s after completed steps %v: %v", eventID, completed, err)
return withStepContext(err, "failed to add attendees to event %s after completed steps %v", eventID, completed)
}
addedCount = len(attendees)
}

View File

@@ -6,68 +6,39 @@ package calendar
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
const (
errCodeInvalidParamsWithDetail = 190014
)
// getErrorDetailValue extracts the first detail value from the output.ErrDetail.
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
// Returns an empty string if the structure doesn't match or the array is empty.
//
// Deprecated: getErrorDetailValue reads from the legacy *output.ErrDetail
// that predates the typed error contract introduced by errs/. New code MUST
// NOT use it — typed errs.* errors expose Message, Hint, and extension
// fields directly on the typed struct via errors.As / errs.ProblemOf. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func getErrorDetailValue(e *output.ErrDetail) string {
if e == nil || e.Detail == nil {
return ""
}
errMap, ok := e.Detail.(map[string]interface{})
if !ok {
return ""
}
details, ok := errMap["details"].([]interface{})
if !ok || len(details) == 0 {
return ""
}
detailObj, ok := details[0].(map[string]interface{})
if !ok {
return ""
}
val, _ := detailObj["value"].(string)
return val
}
// wrapPredefinedError wraps an error into *output.ExitError if it matches predefined error codes.
// Currently handles error code 190014 (invalid params with detail), extracting the detail value into the message.
// If the error is nil or doesn't match predefined codes, returns the original error.
func wrapPredefinedError(err error) error {
// withStepContext annotates err with multi-step context (e.g. which steps
// already completed, or that a rollback ran) while preserving the underlying
// failure's classification. An already-typed error keeps its own
// category/subtype/code/log_id; we only append the formatted context to its
// Hint so the top-level envelope still tells the truth about what failed.
// Only an unclassified error falls back to a typed internal wrap.
func withStepContext(err error, format string, args ...any) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
extra := fmt.Sprintf(format, args...)
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + extra
} else {
p.Hint = extra
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(extra).WithCause(err)
}
if exitErr.Detail.Code == errCodeInvalidParamsWithDetail {
if val := getErrorDetailValue(exitErr.Detail); val != "" {
fullMsg := fmt.Sprintf("%s: %s", exitErr.Detail.Message, val)
return output.ErrAPI(exitErr.Detail.Code, fullMsg, exitErr.Detail.Detail)
}
// withParam attaches the offending flag to a typed validation error, preserving
// the original error instead of re-wrapping it. Non-validation errors pass through.
func withParam(err error, flag string) error {
var ve *errs.ValidationError
if errors.As(err, &ve) {
return ve.WithParam(flag)
}
return err
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newAttendeeValidateRuntime builds a RuntimeContext with the add/remove
// attendee-id flags set, for exercising validateCalendarUpdateAttendees.
func newAttendeeValidateRuntime(t *testing.T, add, remove string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("add-attendee-ids", "", "")
cmd.Flags().String("remove-attendee-ids", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
if add != "" {
_ = cmd.Flags().Set("add-attendee-ids", add)
}
if remove != "" {
_ = cmd.Flags().Set("remove-attendee-ids", remove)
}
return &common.RuntimeContext{Cmd: cmd}
}
// assertValidationParam asserts err is a *errs.ValidationError whose Param
// equals wantParam, and returns it for any further message assertions.
func assertValidationParam(t *testing.T, err error, wantParam string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
return ve
}
// ---------------------------------------------------------------------------
// withStepContext helper
// ---------------------------------------------------------------------------
func TestWithStepContext_Nil(t *testing.T) {
if got := withStepContext(nil, "step %d", 1); got != nil {
t.Fatalf("withStepContext(nil) = %v, want nil", got)
}
}
func TestWithStepContext_AppendsToTypedHint(t *testing.T) {
// A typed error keeps its classification; the context is appended to Hint.
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
got := withStepContext(inner, "after steps %v", []string{"event"})
var ae *errs.APIError
if !errors.As(got, &ae) {
t.Fatalf("want *errs.APIError, got %T", got)
}
if ae.Hint == "" || !strings.Contains(ae.Hint, "first") || !strings.Contains(ae.Hint, "after steps") {
t.Errorf("hint should append context, got %q", ae.Hint)
}
}
func TestWithStepContext_SetsHintWhenEmpty(t *testing.T) {
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom")
got := withStepContext(inner, "after steps %v", []string{"event"})
var ae *errs.APIError
if !errors.As(got, &ae) {
t.Fatalf("want *errs.APIError, got %T", got)
}
if !strings.Contains(ae.Hint, "after steps") {
t.Errorf("hint should be set, got %q", ae.Hint)
}
}
func TestWithStepContext_UnclassifiedFallsBackToInternal(t *testing.T) {
// A plain, unclassified error is wrapped into a typed internal error so the
// envelope still tells the truth.
got := withStepContext(errors.New("raw failure"), "after steps %v", []string{"event"})
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("want *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeSDKError {
t.Errorf("subtype=%q, want sdk_error", ie.Subtype)
}
if !strings.Contains(ie.Message, "raw failure") {
t.Errorf("message should preserve original, got %q", ie.Message)
}
}
// ---------------------------------------------------------------------------
// withParam helper
// ---------------------------------------------------------------------------
func TestWithParam_AttachesToValidationError(t *testing.T) {
inner := errs.NewValidationError(errs.SubtypeInvalidArgument, "boom")
got := withParam(inner, "--attendee-ids")
ve := assertValidationParam(t, got, "--attendee-ids")
if ve != inner {
t.Errorf("withParam should return the same underlying error, got a different pointer")
}
if ve.Message != "boom" {
t.Errorf("message mutated: got %q, want %q", ve.Message, "boom")
}
}
func TestWithParam_NonValidationPassesThrough(t *testing.T) {
inner := errs.NewInternalError(errs.SubtypeSDKError, "io failure")
got := withParam(inner, "--attendee-ids")
if got != inner {
t.Fatalf("non-validation error should pass through unchanged, got %v", got)
}
var ve *errs.ValidationError
if errors.As(got, &ve) {
t.Fatalf("non-validation error must not become a ValidationError")
}
}
func TestWithParam_NilPassesThrough(t *testing.T) {
if got := withParam(nil, "--attendee-ids"); got != nil {
t.Fatalf("withParam(nil) = %v, want nil", got)
}
}
// ---------------------------------------------------------------------------
// Part A — re-wrap sites: the parseAttendees error, attributed by the caller's
// flag, must be the inner typed error (not a re-wrapped nesting).
// ---------------------------------------------------------------------------
func TestParseAttendees_AttributedToCreateFlag(t *testing.T) {
_, err := parseAttendees("bad-id", "")
// create's add path: withParam(err, "--attendee-ids")
got := withParam(err, "--attendee-ids")
assertValidationParam(t, got, "--attendee-ids")
}
func TestParseAttendees_AttributedToAddFlag(t *testing.T) {
_, err := parseAttendees("bad-id", "")
// update's add path: withParam(err, "--add-attendee-ids")
got := withParam(err, "--add-attendee-ids")
assertValidationParam(t, got, "--add-attendee-ids")
}
func TestParseAttendees_InnerStaysFlagAgnostic(t *testing.T) {
// The shared inner parser must not pre-attribute a flag; callers do.
_, err := parseAttendees("bad-id", "")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Param != "" {
t.Errorf("inner parseAttendees should stay flag-agnostic, got Param = %q", ve.Param)
}
}
// ---------------------------------------------------------------------------
// Part B — direct attendee-id format validations carry their flag.
// ---------------------------------------------------------------------------
func TestParseRoomFindAttendees_FormatErrorParam(t *testing.T) {
_, _, err := parseRoomFindAttendees("bad-id", "")
assertValidationParam(t, err, "--"+flagAttendees)
}
func TestParseRoomFindAttendees_RejectsRoomID(t *testing.T) {
// room find only supports ou_/oc_; omm_ rooms are not valid attendees.
_, _, err := parseRoomFindAttendees("omm_room", "")
assertValidationParam(t, err, "--"+flagAttendees)
}
func TestParseCalendarAttendeeIDs_StaysFlagAgnostic(t *testing.T) {
// parseCalendarAttendeeIDs serves BOTH --add-attendee-ids and
// --remove-attendee-ids, so it must not pre-attribute a flag.
_, err := parseCalendarAttendeeIDs("bad-id")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Param != "" {
t.Errorf("shared parser should stay flag-agnostic, got Param = %q", ve.Param)
}
}
func TestValidateCalendarUpdateAttendees_RemoveFormatParam(t *testing.T) {
// The remove path attributes its parser error to --remove-attendee-ids.
rt := newAttendeeValidateRuntime(t, "", "bad-id")
err := validateCalendarUpdateAttendees(rt)
assertValidationParam(t, err, "--remove-attendee-ids")
}
func TestValidateCalendarUpdateAttendees_AddFormatParam(t *testing.T) {
// The add path attributes its parser error to --add-attendee-ids.
rt := newAttendeeValidateRuntime(t, "bad-id", "")
err := validateCalendarUpdateAttendees(rt)
assertValidationParam(t, err, "--add-attendee-ids")
}
// attendeeDeleteIDs's switch default is defensive: parseCalendarAttendeeIDs
// already rejects any non-ou_/oc_/omm_ id, so only a well-formed id reaches the
// switch and the valid branches map it. This asserts the happy path maps types.
func TestAttendeeDeleteIDs_MapsKnownTypes(t *testing.T) {
got, err := attendeeDeleteIDs("ou_a,oc_b,omm_c")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 3 {
t.Fatalf("expected 3 delete ids, got %d: %v", len(got), got)
}
wantTypes := map[string]string{"user": "user_id", "chat": "chat_id", "resource": "room_id"}
for _, m := range got {
key, ok := wantTypes[m["type"]]
if !ok {
t.Errorf("unexpected type %q in %v", m["type"], m)
continue
}
if m[key] == "" {
t.Errorf("missing %s for type %q in %v", key, m["type"], m)
}
}
}
func TestParseCalendarAttendeeIDs_Valid(t *testing.T) {
ids, err := parseCalendarAttendeeIDs(" ou_a , oc_b , ou_a ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 2 || ids[0] != "ou_a" || ids[1] != "oc_b" {
t.Errorf("dedup/trim failed: got %v", ids)
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -198,3 +199,58 @@ func TestCallAPITyped_NonObjectJSON(t *testing.T) {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
}
// TestDoAPIJSONTyped_Success returns the data object on code 0, confirming the
// typed DoAPIJSON replacement preserves the success contract of DoAPIJSON.
func TestDoAPIJSONTyped_Success(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/x/z",
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"id": "z1"}},
})
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data["id"] != "z1" {
t.Errorf("data[id] = %v, want z1", data["id"])
}
}
func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) {
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser)
rt.apiClientFunc = func() (*client.APIClient, error) {
return nil, errors.New("raw client construction error")
}
_, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected raw client errors to be lifted to typed internal errors, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeUnknown)
}
}
// TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed
// errs.* error (carrying log_id), never a legacy output.ExitError envelope.
func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/z",
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "lz"},
})
_, err := rt.DoAPIJSONTyped("POST", "/open-apis/x/z", nil, map[string]any{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if p.LogID != "lz" {
t.Errorf("LogID = %q, want lz", p.LogID)
}
}

View File

@@ -15,6 +15,7 @@ import (
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/output"
)
@@ -57,6 +58,7 @@ type DriveMediaMultipartUploadConfig struct {
Reader io.Reader
}
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
@@ -98,6 +100,52 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
}
// UploadDriveMediaAllTyped is the typed-error counterpart of
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
// transport failures as typed network errors, and API failures are classified
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
fileReader = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatErrorTyped(err)
}
defer f.Close()
fileReader = f
}
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
fd.AddField("parent_type", cfg.ParentType)
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
if cfg.ParentNode != nil {
fd.AddField("parent_node", *cfg.ParentNode)
}
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
}
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
@@ -130,6 +178,43 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
}
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
// CallAPITyped, malformed session plans surface as invalid-response internal
// errors, and per-part transport/API failures are classified the same way as
// UploadDriveMediaAllTyped.
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
// string instead of relying on field omission like upload_all does.
prepareBody := map[string]interface{}{
"file_name": cfg.FileName,
"parent_type": cfg.ParentType,
"parent_node": cfg.ParentNode,
"size": cfg.FileSize,
}
if cfg.Extra != "" {
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
}
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
// The backend chooses both chunk size and chunk count. Validate them once so
// the streaming loop can follow the returned plan without re-checking shape.
@@ -280,3 +365,122 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
}
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
// message so callers see which upload step failed. Non-typed errors are
// returned unchanged.
func prefixDriveMediaUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
}
return err
}
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
// malformed plan as a typed invalid-response internal error.
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
session := DriveMediaMultipartUploadSession{
UploadID: GetString(data, "upload_id"),
BlockSize: int64(GetFloat(data, "block_size")),
BlockNum: int(GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
}
return session, nil
}
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
// with a typed invalid-response internal error for a missing file_token.
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
}
return fileToken, nil
}
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
// with typed errors for file-open, file-read, and per-part upload failures.
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
var r io.Reader
if cfg.Reader != nil {
r = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return WrapInputStatErrorTyped(err)
}
defer f.Close()
r = f
}
maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := cfg.FileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
if readErr != nil {
return WrapInputStatErrorTyped(readErr)
}
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
remaining -= int64(n)
}
return nil
}
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
}
return nil
}
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
}

View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_123"},
},
})
payload := []byte{0x89, 0x50, 0x4e, 0x47}
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
}
if fileToken != "file_typed_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123")
}
}
func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 999,
"msg": "upload rejected",
},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("category = %s, want api", p.Category)
}
if p.Code != 999 {
t.Fatalf("code = %d, want 999", p.Code)
}
if !strings.HasPrefix(p.Message, "upload media failed: ") || !strings.Contains(p.Message, "upload rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaAllTypedFileOpenFailure(t *testing.T) {
runtime, _ := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
FilePath: "missing.bin",
FileName: "missing.bin",
FileSize: 1,
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected typed validation error, got %T (%v)", err, err)
}
}
func TestUploadDriveMediaMultipartTypedBuildsPreparePartsAndFinish(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_typed_1",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_multi"},
},
})
payload := bytes.Repeat([]byte{0xCD}, int(size))
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
}
if fileToken != "file_typed_multi" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_multi")
}
}
func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
})
}
}
func TestUploadDriveMediaMultipartTypedPartAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI || p.Code != 999 {
t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code)
}
if !strings.HasPrefix(p.Message, "upload media part failed: ") || !strings.Contains(p.Message, "chunk rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
if !strings.Contains(p.Message, "upload media finish failed: no file_token returned") {
t.Fatalf("message = %q", p.Message)
}
}

View File

@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
body["with_url"] = true
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,

View File

@@ -10,6 +10,7 @@ import (
"sync/atomic"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
if err == nil {
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Code != 99991668 {
t.Fatalf("code = %d, want 99991668", p.Code)
}
})
}

View File

@@ -492,6 +492,28 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark
return ctx.doAPIJSON(method, apiPath, query, body, true)
}
// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same
// larkcore.ApiReq request (identical method / path / query / body model) but
// classifies failures into typed errs.* errors via ClassifyAPIResponse instead
// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth
// error from the client boundary is already typed and passes through unchanged;
// a non-zero API code is classified with subtype / code / log_id.
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
QueryParams: query,
}
if body != nil {
req.Body = body
}
resp, err := ctx.DoAPI(req)
if err != nil {
return nil, typedOrInternal(err)
}
return ctx.ClassifyAPIResponse(resp)
}
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
@@ -603,27 +625,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
return resolved, nil
}
// WrapSaveError matches a FileIO.Save error against known categories and wraps
// it with the caller-provided message prefix, preserving backward-compatible
// error text per shortcut.
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return fmt.Errorf("%s: %w", pathMsg, err)
case errors.As(err, &me):
return fmt.Errorf("%s: %w", mkdirMsg, err)
case errors.As(err, &we):
return fmt.Errorf("%s: %w", writeMsg, err)
default:
return fmt.Errorf("%s: %w", writeMsg, err)
}
}
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
// caller-provided message prefix.
func WrapOpenError(err error, pathMsg, readMsg string) error {
@@ -703,6 +704,9 @@ func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):

View File

@@ -4,7 +4,6 @@
package common
import (
"fmt"
"strconv"
"strings"
@@ -107,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
return MutuallyExclusiveTyped(rt, flags...)
}
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
//
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
}
if n < minVal || n > maxVal {
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
}
return n, nil
}
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
@@ -177,25 +157,6 @@ func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
//
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
func RejectDangerousChars(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r)
}
if r == 0x7F {
return fmt.Errorf("parameter %q contains DEL character", paramName)
}
if IsDangerousUnicode(r) {
return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r)
}
}
return nil
}
// RejectDangerousCharsTyped returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
func RejectDangerousCharsTyped(paramName, value string) error {

View File

@@ -9,18 +9,6 @@ import (
"github.com/larksuite/cli/internal/output"
)
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided.
//
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
func ValidateChatID(input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return chatID, nil
}
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided. param names the flag being
// validated (e.g. "--chat-ids") and is recorded on the typed error.

View File

@@ -194,6 +194,21 @@ func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
}
}
func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) {
typed := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500: chunk failed").
WithCode(500)
err := WrapSaveErrorTyped(&fileio.WriteError{Err: typed})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != 500 {
t.Fatalf("problem = category %q subtype %q code %d, want network/%s/500",
p.Category, p.Subtype, p.Code, errs.SubtypeNetworkServer)
}
}
func TestAtLeastOne(t *testing.T) {
tests := []struct {
name string

View File

@@ -5,35 +5,13 @@ package doc
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
// v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands.
func v1CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title", Hidden: true},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
}
}
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Create(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("content") != "" ||
runtime.Str("parent-token") != "" ||
runtime.Str("parent-position") != ""
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
}
var DocsCreate = common.Shortcut{
@@ -43,213 +21,25 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Tips: docsVersionSelectionTips,
PostMount: installDocsShortcutHelp("+create"),
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
docsAPIVersionCompatFlag(),
},
v1CreateFlags(),
v2CreateFlags(),
v1CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
}
return validateCreateV1(ctx, runtime)
return validateCreateV2(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
}
return dryRunCreateV1(ctx, runtime)
return dryRunCreateV2(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
}
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
return executeCreateV2(ctx, runtime)
},
}
// ── V1 (MCP) implementation ──
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("markdown") == "" {
return common.FlagErrorf("--markdown is required")
}
count := 0
if runtime.Str("folder-token") != "" {
count++
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
}
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildCreateArgsV1(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentCreateResultV1(runtime, result)
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
md := runtime.Str("markdown")
args := map[string]interface{}{
"markdown": md,
}
if v := runtime.Str("title"); v != "" {
args["title"] = v
}
if v := runtime.Str("folder-token"); v != "" {
args["folder_token"] = v
}
if v := runtime.Str("wiki-node"); v != "" {
args["wiki_node"] = v
}
if v := runtime.Str("wiki-space"); v != "" {
args["wiki_space"] = v
}
return args
}
type docsPermissionTarget struct {
Token string
Type string
}
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
fallbackDocURLV1(runtime, result)
}
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
// response did not include one but did include a doc_id. This protects against
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
// drops structured fields.
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
return
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID == "" {
return
}
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
result["doc_url"] = u
}
}
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
}
return docsPermissionTarget{}
}
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
case "doc", "docx":
return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true
default:
return docsPermissionTarget{}, false
}
}
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
// whiteboard creation markdown is detected.
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
}
// ── Shared helpers ──
// concatFlags combines multiple flag slices into one.
func concatFlags(slices ...[]common.Flag) []common.Flag {
var out []common.Flag
@@ -258,15 +48,3 @@ func concatFlags(slices ...[]common.Flag) []common.Flag {
}
return out
}
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
m := make(map[string]string, len(v1)+len(v2))
for _, f := range v1 {
m[f.Name] = "v1"
}
for _, f := range v2 {
m[f.Name] = "v2"
}
return m
}

View File

@@ -48,7 +48,6 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
@@ -249,148 +248,63 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
}
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/wiki/wikcn_new_node",
"message": "文档创建成功",
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--wiki-space", "my_library",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["perm_type"] != "container" {
t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container")
}
}
func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"message": "文档创建成功",
// "doc_url" deliberately omitted to exercise the fallback.
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--content", "<title>项目计划</title>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
doc, _ := data["document"].(map[string]interface{})
if got, want := doc["document_id"], "doxcn_new_doc"; got != want {
t.Fatalf("document.document_id = %#v, want %q", got, want)
}
}
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
if err == nil {
t.Fatal("expected legacy v1 flags to be rejected")
}
data := decodeDocsCreateEnvelope(t, stdout)
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
for _, want := range []string{
"docs +create is v2-only",
"the old v1 interface has been shut down",
"legacy v1 flag(s) --title, --markdown are no longer supported",
"--title -> put the title in --content",
"--markdown -> use --content with --doc-format markdown",
"lark-cli skills read lark-doc references/lark-doc-create.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"follow the latest format rules",
"MUST NOT grep/open local SKILL.md files",
"lark-cli docs +create --help",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
}
@@ -421,24 +335,6 @@ func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface
})
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/mcp",
Body: map[string]interface{}{
"result": map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": string(payload),
},
},
},
},
})
}
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()

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