mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
24 Commits
feat/eval_
...
sun/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 | ||
|
|
a4a4bd6ee0 | ||
|
|
ac116e7ca3 | ||
|
|
5e6a3eb857 | ||
|
|
493b3cce95 | ||
|
|
abc0553f21 | ||
|
|
a82a486508 | ||
|
|
c000dc3a44 | ||
|
|
256df8c0fb | ||
|
|
7a0dbe057b | ||
|
|
8ce38793a7 | ||
|
|
54e646edc9 | ||
|
|
b07a6003f9 | ||
|
|
03a589978f | ||
|
|
b3fcf55611 | ||
|
|
2f35ce3724 | ||
|
|
7e7f716a82 | ||
|
|
1670a794f6 | ||
|
|
33de28fd1a | ||
|
|
85c7280d8b |
@@ -57,6 +57,14 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
@@ -65,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/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
|
||||
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/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/|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/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
@@ -107,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: >-
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,43 @@
|
||||
|
||||
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
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
@@ -989,6 +1026,8 @@ 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
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
|
||||
@@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||
// inherited by every subcommand, turning unknown-flag errors into a
|
||||
// structured "did you mean" envelope.
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
|
||||
160
cmd/cmdexample_catalog_test.go
Normal file
160
cmd/cmdexample_catalog_test.go
Normal 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)]
|
||||
}
|
||||
60
cmd/cmdexample_check_test.go
Normal file
60
cmd/cmdexample_check_test.go
Normal 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
|
||||
}
|
||||
222
cmd/cmdexample_parse_test.go
Normal file
222
cmd/cmdexample_parse_test.go
Normal 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
113
cmd/cmdexample_test.go
Normal 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
|
||||
}
|
||||
233
cmd/cmdexample_units_test.go
Normal file
233
cmd/cmdexample_units_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
@@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error {
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
@@ -10,27 +10,6 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
70
cmd/flag_suggest_test.go
Normal file
70
cmd/flag_suggest_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
valid, _ := detail["valid_flags"].([]string)
|
||||
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
}
|
||||
}
|
||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
377
cmd/root.go
377
cmd/root.go
@@ -18,14 +18,17 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
@@ -69,7 +72,15 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -133,29 +144,49 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -260,6 +291,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -301,6 +345,12 @@ func asExitError(err error) *output.ExitError {
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
// consumes no flags itself, so unknown flags belong to the (missing)
|
||||
// subcommand; whitelisting them here prevents cobra from erroring on the
|
||||
// flag first and printing usage instead of our structured suggestion.
|
||||
cmd.FParseErrWhitelist.UnknownFlags = true
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
@@ -320,14 +370,89 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
@@ -335,17 +460,114 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -354,10 +576,95 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
|
||||
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
|
||||
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
|
||||
// --find, where edit distance alone finds nothing). Other flag errors stay
|
||||
// structured but generic.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "flag_error",
|
||||
Message: ferr.Error(),
|
||||
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
|
||||
},
|
||||
}
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": "--" + name,
|
||||
"command_path": c.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"valid_flags": valid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
// the valid_flags detail).
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,149 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "--badflag" {
|
||||
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||
}
|
||||
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||
}
|
||||
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||
if _, present := detail[key]; !present {
|
||||
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||
// must still fail structured (missing_subcommand) rather than fall through to
|
||||
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -113,11 +257,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||
// back to pointing at --help; the full machine-readable list lives in
|
||||
// detail.available below (which also excludes hidden commands).
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
@@ -164,7 +308,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
got, _ := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -175,3 +319,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ func successfulSkillsCommand() func(args ...string) *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:
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmdpolicy
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
|
||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||
}
|
||||
best := string(candidates[0])
|
||||
bestDist := levenshtein(lowered, best)
|
||||
bestDist := suggest.Levenshtein(lowered, best)
|
||||
for _, c := range candidates[1:] {
|
||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
||||
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
|
||||
bestDist, best = d, string(c)
|
||||
}
|
||||
}
|
||||
@@ -40,47 +41,3 @@ func toLower(s string) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// levenshtein computes the classic edit distance between two strings.
|
||||
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
|
||||
// makes raw performance irrelevant — clarity beats trickiness here.
|
||||
func levenshtein(a, b string) int {
|
||||
if len(a) == 0 {
|
||||
return len(b)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return len(a)
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
curr := make([]int, len(b)+1)
|
||||
for j := 0; j <= len(b); j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min3(
|
||||
prev[j]+1, // deletion
|
||||
curr[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"wrtie", "write", 2},
|
||||
{"kitten", "sitting", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := levenshtein(c.a, c.b)
|
||||
if got != c.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
16
internal/errclass/codemeta_calendar.go
Normal file
16
internal/errclass/codemeta_calendar.go
Normal 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") }
|
||||
39
internal/errclass/codemeta_calendar_test.go
Normal file
39
internal/errclass/codemeta_calendar_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
internal/errclass/codemeta_mail.go
Normal file
20
internal/errclass/codemeta_mail.go
Normal 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") }
|
||||
@@ -231,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))
|
||||
@@ -257,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,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
|
||||
@@ -290,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)
|
||||
@@ -300,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
|
||||
@@ -312,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -165,6 +165,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 {
|
||||
|
||||
@@ -188,6 +188,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 {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -57,6 +58,28 @@ 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)
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -77,8 +100,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 +117,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{}
|
||||
@@ -195,6 +224,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
@@ -239,10 +269,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- 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 +327,24 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -67,6 +67,49 @@ 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 TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -113,14 +156,18 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
@@ -146,6 +193,19 @@ 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) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
@@ -153,7 +213,16 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
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 +255,9 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
|
||||
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 +269,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 {
|
||||
@@ -262,47 +338,73 @@ 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"),
|
||||
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",
|
||||
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{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +446,7 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
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,
|
||||
@@ -375,6 +478,7 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
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"),
|
||||
@@ -473,6 +577,7 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
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,
|
||||
@@ -497,6 +602,7 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
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,
|
||||
@@ -537,8 +643,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("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" {
|
||||
|
||||
104
internal/suggest/suggest.go
Normal file
104
internal/suggest/suggest.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package suggest provides the shared "did you mean" primitives: a rune-aware
|
||||
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
|
||||
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
|
||||
// carrying their own copy.
|
||||
package suggest
|
||||
|
||||
import "sort"
|
||||
|
||||
// Levenshtein computes the classic edit distance between two strings. It is
|
||||
// rune-aware, so it is correct for multi-byte input.
|
||||
func Levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
// Closest returns up to maxN of candidates that plausibly match typed, ranked
|
||||
// by shared-prefix length (desc) then edit distance (asc), keeping only
|
||||
// reasonably-close ones.
|
||||
//
|
||||
// Shared prefix is weighted first on purpose: hallucinated names are often
|
||||
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
|
||||
// "--with-styles" vs nothing close), where the common prefix is the strongest
|
||||
// signal of intent that raw edit distance misses.
|
||||
func Closest(typed string, candidates []string, maxN int) []string {
|
||||
type scored struct {
|
||||
name string
|
||||
prefix int
|
||||
dist int
|
||||
}
|
||||
limit := editLimit(typed)
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
p := sharedPrefixLen(typed, c)
|
||||
d := Levenshtein(typed, c)
|
||||
// Keep only plausible matches: a meaningful shared prefix, or an edit
|
||||
// distance within budget. Drop everything else so the hint stays short.
|
||||
if p >= 3 || d <= limit {
|
||||
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
|
||||
}
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].prefix != ranked[j].prefix {
|
||||
return ranked[i].prefix > ranked[j].prefix
|
||||
}
|
||||
if ranked[i].dist != ranked[j].dist {
|
||||
return ranked[i].dist < ranked[j].dist
|
||||
}
|
||||
return ranked[i].name < ranked[j].name
|
||||
})
|
||||
if maxN <= 0 || maxN > len(ranked) {
|
||||
maxN = len(ranked)
|
||||
}
|
||||
out := make([]string, 0, maxN)
|
||||
for _, s := range ranked[:maxN] {
|
||||
out = append(out, s.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// editLimit allows roughly one third of the typed length in edits (min 2), so
|
||||
// short names tolerate a couple of typos and longer ones proportionally more.
|
||||
func editLimit(s string) int {
|
||||
if l := len([]rune(s)) / 3; l > 2 {
|
||||
return l
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func sharedPrefixLen(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
n := 0
|
||||
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
74
internal/suggest/suggest_test.go
Normal file
74
internal/suggest/suggest_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
|
||||
cmds := []string{
|
||||
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
|
||||
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
|
||||
"+pivot-create", "+sheet-info",
|
||||
}
|
||||
// "+cells-find" is semantically +cells-search but lexically far; the shared
|
||||
// "+cells-" prefix should still surface the right family (incl. +cells-search).
|
||||
got := Closest("+cells-find", cmds, 6)
|
||||
if len(got) == 0 || len(got) > 6 {
|
||||
t.Fatalf("expected 1..6 suggestions, got %v", got)
|
||||
}
|
||||
if !slices.Contains(got, "+cells-search") {
|
||||
t.Errorf("expected +cells-search among suggestions, got %v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if len(s) < 7 || s[:7] != "+cells-" {
|
||||
t.Errorf("suggestion %q does not share the +cells- prefix", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
|
||||
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
|
||||
if len(got) == 0 || got[0] != "+cells-get" {
|
||||
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_NoPlausibleMatch(t *testing.T) {
|
||||
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
|
||||
t.Errorf("expected no suggestions for unrelated input, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"kitten", "sitting", 3},
|
||||
{"cell-get", "cells-get", 1},
|
||||
{"--query", "--find", 5},
|
||||
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
|
||||
{"飞书", "飞s", 1}, // one rune substitution, not byte count
|
||||
}
|
||||
for _, c := range cases {
|
||||
if d := Levenshtein(c.a, c.b); d != c.want {
|
||||
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPrefixLen(t *testing.T) {
|
||||
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 7", got)
|
||||
}
|
||||
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
141
lint/errscontract/rule_no_legacy_common_helper_call.go
Normal file
141
lint/errscontract/rule_no_legacy_common_helper_call.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
|
||||
// has migrated to typed errs.* envelopes. On these paths, calls to common's
|
||||
// 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/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/calendar/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
var legacyCommonHelperReplacements = map[string]string{
|
||||
"FlagErrorf": "common.ValidationErrorf",
|
||||
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
|
||||
"AtLeastOne": "common.AtLeastOneTyped",
|
||||
"ExactlyOne": "common.ExactlyOneTyped",
|
||||
"ValidatePageSize": "common.ValidatePageSizeTyped",
|
||||
"ValidateChatID": "common.ValidateChatIDTyped",
|
||||
"ValidateUserID": "common.ValidateUserIDTyped",
|
||||
"ValidateSafePath": "common.ValidateSafePathTyped",
|
||||
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
|
||||
"WrapInputStatError": "common.WrapInputStatErrorTyped",
|
||||
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
|
||||
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
|
||||
"HandleApiResult": "runtime.CallAPITyped",
|
||||
}
|
||||
|
||||
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
|
||||
// APIs on migrated paths — direct calls and function-value references alike,
|
||||
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
|
||||
// helpers return legacy output envelopes or bare errors, so migrated domains
|
||||
// should use their typed-aware replacements.
|
||||
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
|
||||
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
localNames, dotImported := resolveCommonNames(file)
|
||||
var out []Violation
|
||||
report := func(pos token.Pos, name, replacement string) {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_common_helper_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
|
||||
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
|
||||
})
|
||||
}
|
||||
// Pass 1: qualified references (common.X / alias.X). Record every
|
||||
// selector field so the dot-import pass below never mistakes another
|
||||
// package's same-named field for a common helper.
|
||||
selFields := make(map[*ast.Ident]struct{})
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selFields[sel.Sel] = struct{}{}
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
|
||||
report(sel.Pos(), sel.Sel.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Pass 2: unqualified references under a dot import.
|
||||
if dotImported {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ident, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, isField := selFields[ident]; isField {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
|
||||
report(ident.Pos(), ident.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isMigratedCommonHelperPath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedCommonHelperPaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != commonImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
names["common"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
@@ -16,7 +16,10 @@ 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/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/calendar/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -662,7 +662,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/im/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -877,3 +877,150 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
t.Errorf("test files must be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
|
||||
helpers := []string{
|
||||
"FlagErrorf",
|
||||
"MutuallyExclusive",
|
||||
"AtLeastOne",
|
||||
"ExactlyOne",
|
||||
"ValidatePageSize",
|
||||
"ValidateChatID",
|
||||
"ValidateUserID",
|
||||
"ValidateSafePath",
|
||||
"RejectDangerousChars",
|
||||
"WrapInputStatError",
|
||||
"WrapSaveErrorByCategory",
|
||||
"ResolveOpenIDs",
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.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 + `()
|
||||
}
|
||||
`
|
||||
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
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.ValidationErrorf("typed")
|
||||
common.MutuallyExclusiveTyped(nil, "a", "b")
|
||||
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
|
||||
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
|
||||
common.WrapSaveErrorTyped(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed helpers must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
c.FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() error {
|
||||
f := common.FlagErrorf
|
||||
return f("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
|
||||
// Typed-error invariants — self-scope to errs/ + classify.go.
|
||||
all = append(all, CheckNilSafeError(rel, string(src))...)
|
||||
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.46",
|
||||
"version": "1.0.48",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
42
shortcuts/base/base_block_create.go
Normal file
42
shortcuts/base/base_block_create.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-create",
|
||||
Description: "Create a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
|
||||
"Creates a folder, table, docx, dashboard, or workflow entry.",
|
||||
"Do not pass null for --parent-id. Omit it to create at the root level.",
|
||||
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockCreate(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockCreate(runtime)
|
||||
},
|
||||
}
|
||||
35
shortcuts/base/base_block_delete.go
Normal file
35
shortcuts/base/base_block_delete.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-delete",
|
||||
Description: "Delete a block",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:block:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Deletes the block identified by --block-id.",
|
||||
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
|
||||
"Different block types may have independent backing resources; deletion follows backend semantics.",
|
||||
"Use +base-block-list first when you need to confirm the target block id.",
|
||||
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockDelete(runtime)
|
||||
},
|
||||
}
|
||||
43
shortcuts/base/base_block_list.go
Normal file
43
shortcuts/base/base_block_list.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockList = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-list",
|
||||
Description: "List blocks in a base",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:block:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token>",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
|
||||
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
|
||||
"This command returns the full backend list. It intentionally does not expose limit or offset.",
|
||||
"Pass --type to list only one resource type.",
|
||||
"Pass --parent-id to list only direct children of a folder.",
|
||||
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockList(runtime)
|
||||
},
|
||||
}
|
||||
42
shortcuts/base/base_block_move.go
Normal file
42
shortcuts/base/base_block_move.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockMove = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-move",
|
||||
Description: "Move a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
|
||||
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
|
||||
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
"Omit --parent-id to move the block to root; do not pass null.",
|
||||
"--before-id and --after-id are mutually exclusive.",
|
||||
"When moving a folder, its children remain under that folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockMove(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockMove,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockMove(runtime)
|
||||
},
|
||||
}
|
||||
179
shortcuts/base/base_block_ops.go
Normal file
179
shortcuts/base/base_block_ops.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
|
||||
|
||||
func baseBlockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
|
||||
}
|
||||
|
||||
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(buildBaseBlockListBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks").
|
||||
Body(buildBaseBlockCreateBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
|
||||
Body(buildBaseBlockMoveBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
|
||||
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return baseFlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
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 baseFlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return baseFlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockList(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
|
||||
if blockType == "" {
|
||||
return
|
||||
}
|
||||
blocks, ok := data["blocks"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]interface{})
|
||||
if !ok || blockMap["type"] != blockType {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
data["blocks"] = filtered
|
||||
data["total"] = len(filtered)
|
||||
}
|
||||
|
||||
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"type": strings.TrimSpace(runtime.Str("type")),
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"parent_id": nil}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
|
||||
body["before_id"] = beforeID
|
||||
}
|
||||
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
|
||||
body["after_id"] = afterID
|
||||
}
|
||||
return body
|
||||
}
|
||||
37
shortcuts/base/base_block_rename.go
Normal file
37
shortcuts/base/base_block_rename.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockRename = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-rename",
|
||||
Description: "Rename a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
|
||||
"Renames the block identified by --block-id.",
|
||||
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
|
||||
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockRename(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockRename,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockRename(runtime)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -32,6 +32,29 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
}
|
||||
|
||||
func TestDryRunBaseBlockOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
|
||||
|
||||
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
|
||||
|
||||
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
|
||||
|
||||
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
|
||||
|
||||
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
|
||||
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
|
||||
}
|
||||
|
||||
func TestDryRunFieldOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -411,6 +412,167 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
listStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
|
||||
},
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
},
|
||||
}
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
|
||||
},
|
||||
}
|
||||
renameStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
|
||||
},
|
||||
}
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc"},
|
||||
},
|
||||
}
|
||||
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
|
||||
t.Fatalf("list err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
|
||||
t.Fatalf("list stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
|
||||
t.Fatalf("list body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("create stdout=%s", got)
|
||||
}
|
||||
createBody := decodeCapturedJSONBody(t, createStub)
|
||||
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
|
||||
t.Fatalf("create body=%#v", createBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
|
||||
t.Fatalf("move err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
|
||||
t.Fatalf("move stdout=%s", got)
|
||||
}
|
||||
moveBody := decodeCapturedJSONBody(t, moveStub)
|
||||
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
|
||||
t.Fatalf("move body=%#v", moveBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
|
||||
t.Fatalf("rename stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
|
||||
t.Fatalf("rename body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("delete err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("delete stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -769,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"},
|
||||
},
|
||||
@@ -1991,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) {
|
||||
@@ -2160,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{
|
||||
@@ -2356,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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
@@ -188,6 +189,7 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
@@ -241,6 +243,30 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
err := validateBaseBlockMove(runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
|
||||
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
|
||||
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
|
||||
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -728,6 +754,79 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: BaseBaseBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-list --base-token <base_token>",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`--type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"returned id is the table-id, dashboard-id, or workflow-id",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: BaseBaseBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Recursive folder deletion is not supported.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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- "))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -8,6 +8,11 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
BaseBaseBlockRename,
|
||||
BaseBaseBlockDelete,
|
||||
BaseTableList,
|
||||
BaseTableGet,
|
||||
BaseTableCreate,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
242
shortcuts/calendar/errors_attribution_test.go
Normal file
242
shortcuts/calendar/errors_attribution_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,9 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
|
||||
}
|
||||
|
||||
// HandleApiResult checks for network/API errors and returns the "data" field.
|
||||
//
|
||||
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
|
||||
// self-driven requests) for typed error envelopes.
|
||||
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
305
shortcuts/common/drive_media_upload_typed_test.go
Normal file
305
shortcuts/common/drive_media_upload_typed_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RuntimeContext provides helpers for shortcut execution.
|
||||
@@ -72,6 +73,16 @@ func (ctx *RuntimeContext) IsBot() bool {
|
||||
return ctx.As().IsBot()
|
||||
}
|
||||
|
||||
// Command returns the shortcut command name as cobra knows it (e.g.
|
||||
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
|
||||
// validation) that key off the shortcut identity.
|
||||
func (ctx *RuntimeContext) Command() string {
|
||||
if ctx.Cmd == nil {
|
||||
return ""
|
||||
}
|
||||
return ctx.Cmd.Name()
|
||||
}
|
||||
|
||||
// UserOpenId returns the current user's open_id from config.
|
||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
|
||||
@@ -200,6 +211,12 @@ func (ctx *RuntimeContext) Int(name string) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// Float64 returns a float64 flag value (non-integer numbers).
|
||||
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -625,6 +642,8 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
// - Other errors → readMsg prefix (default "cannot read file")
|
||||
//
|
||||
// Pass an optional readMsg to override the non-path-validation message prefix.
|
||||
//
|
||||
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
|
||||
func WrapInputStatError(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -639,9 +658,28 @@ func WrapInputStatError(err error, readMsg ...string) error {
|
||||
return output.ErrValidation("%s: %s", msg, err)
|
||||
}
|
||||
|
||||
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
|
||||
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
msg := "cannot read file"
|
||||
if len(readMsg) > 0 && readMsg[0] != "" {
|
||||
msg = readMsg[0]
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
||||
// using standardized messages and the given error category (e.g. "api_error", "io").
|
||||
// Path validation errors always use ErrValidation (exit code 2).
|
||||
//
|
||||
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
|
||||
func WrapSaveErrorByCategory(err error, category string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -657,6 +695,28 @@ func WrapSaveErrorByCategory(err error, category string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
|
||||
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
|
||||
// "internal" wire type: call sites migrating from a custom category
|
||||
// (e.g. "io", "api_error") change their envelope's type field.
|
||||
func WrapSaveErrorTyped(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 errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePath checks that path is a valid relative input path within the
|
||||
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
||||
// valid or does not exist yet; returns an error only for illegal paths
|
||||
@@ -895,6 +955,29 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
}
|
||||
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
||||
onInvoke := shortcut.OnInvoke
|
||||
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
||||
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
||||
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
||||
// surface even when the call later fails on a missing required flag.
|
||||
// - --print-schema: pure local introspection; relax the required-flag
|
||||
// gate so callers don't fill in unrelated flags just to ask for a
|
||||
// schema (clearing the annotation here is the supported opt-out).
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if onInvoke != nil {
|
||||
onInvoke()
|
||||
}
|
||||
if relaxRequiredForSchema {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); want {
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
@@ -908,6 +991,31 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
// only registered when the shortcut opts in via PrintFlagSchema.
|
||||
if s.PrintFlagSchema != nil {
|
||||
if want, _ := cmd.Flags().GetBool("print-schema"); want {
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// structured ExitError so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if _, ok := err.(*output.ExitError); !ok {
|
||||
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, string(out))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1012,6 +1120,16 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
|
||||
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
|
||||
// occurrences are left untouched.
|
||||
func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
@@ -1022,7 +1140,8 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
|
||||
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
@@ -1031,17 +1150,23 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(fl.Input, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1054,17 +1179,23 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(fl.Input, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
|
||||
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1088,7 +1219,8 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
|
||||
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1096,7 +1228,8 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
|
||||
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if s.DryRun == nil {
|
||||
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
|
||||
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
||||
WithParam("--dry-run")
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
|
||||
dryResult := s.DryRun(rctx.ctx, rctx)
|
||||
@@ -1149,6 +1282,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d int
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "float64":
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
@@ -1183,6 +1320,17 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if s.PrintFlagSchema != nil {
|
||||
// Guard against a shortcut that already declares these reserved
|
||||
// introspection flags: pflag panics on a duplicate registration.
|
||||
// Mirrors the Lookup guard on --format above.
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,46 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
|
||||
// --print-schema / --flag-name flags are registered defensively: a shortcut
|
||||
// that already declares same-named flags must not trigger pflag's duplicate-
|
||||
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
|
||||
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+introspect",
|
||||
Description: "x",
|
||||
// The shortcut's own flags collide with the names the runner auto-
|
||||
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
|
||||
Flags: []Flag{
|
||||
{Name: "print-schema", Desc: "user-defined collision"},
|
||||
{Name: "flag-name", Desc: "user-defined collision"},
|
||||
},
|
||||
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
|
||||
}
|
||||
}()
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+introspect"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
t.Error("print-schema flag should still exist after the guarded registration")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
t.Error("flag-name flag should still exist after the guarded registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -142,6 +143,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -171,6 +174,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -212,7 +216,58 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||
{"no BOM unchanged", "hello", "hello"},
|
||||
{"empty unchanged", "", ""},
|
||||
{"only BOM becomes empty", "\uFEFF", ""},
|
||||
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
|
||||
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripUTF8BOM(c.in); got != c.want {
|
||||
t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
|
||||
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
|
||||
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
|
||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
|
||||
t.Errorf("leading BOM not stripped from stdin, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
|
||||
// with "invalid character 'ï'".
|
||||
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
|
||||
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
|
||||
t.Errorf("leading BOM not stripped from file, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
22
shortcuts/common/runner_validation_test.go
Normal file
22
shortcuts/common/runner_validation_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
|
||||
rctx := newTestRuntime(map[string]string{"mode": "delete"})
|
||||
err := validateEnumFlags(rctx, []Flag{
|
||||
{Name: "mode", Enum: []string{"append", "overwrite"}},
|
||||
})
|
||||
assertValidationParam(t, err, "--mode")
|
||||
}
|
||||
|
||||
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
|
||||
err := handleShortcutDryRun(nil, nil, &Shortcut{
|
||||
Service: "doc",
|
||||
Command: "fetch",
|
||||
})
|
||||
assertValidationParam(t, err, "--dry-run")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user