mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
108 Commits
codex/insp
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2aa27dac8 | ||
|
|
3bca545796 | ||
|
|
a8b29a1cf1 | ||
|
|
9c7d5a4b96 | ||
|
|
ce9764ec2e | ||
|
|
fc6e60ba5b | ||
|
|
5aba007f57 | ||
|
|
5c22f912aa | ||
|
|
7dd479df12 | ||
|
|
46066de29e | ||
|
|
e58fa13716 | ||
|
|
f99b5bf32e | ||
|
|
74761a0e1c | ||
|
|
7d24e2b649 | ||
|
|
e3eca666fb | ||
|
|
da65e37647 | ||
|
|
dce617eab2 | ||
|
|
9c3d30aa00 | ||
|
|
690e746896 | ||
|
|
0f695b60ec | ||
|
|
77f86ec2fd | ||
|
|
8e84f47d3e | ||
|
|
5ebb1398e7 | ||
|
|
0476dec83c | ||
|
|
69d2851163 | ||
|
|
ce5878e3c4 | ||
|
|
e85afd68d2 | ||
|
|
a09593a0fe | ||
|
|
60c61d8157 | ||
|
|
08e8b5c870 | ||
|
|
338cdaa6db | ||
|
|
55ccbc5f6a | ||
|
|
bea4c746ae | ||
|
|
4b16fe9ce0 | ||
|
|
f53e55ce65 | ||
|
|
71eae77f65 | ||
|
|
08d025945e | ||
|
|
930c9c77a8 | ||
|
|
bb7ccaedf9 | ||
|
|
a0d6472e9f | ||
|
|
6b3c0b5556 | ||
|
|
f4bcb85d2e | ||
|
|
5880d070e2 | ||
|
|
2082095f18 | ||
|
|
6cadbe807a | ||
|
|
927a73faa2 | ||
|
|
9c447e735b | ||
|
|
12b94746bb | ||
|
|
5327e9390d | ||
|
|
dece428487 | ||
|
|
ff493d4534 | ||
|
|
ff78ff40d8 | ||
|
|
1a2d2d04be | ||
|
|
5eaa70b74a | ||
|
|
f0dea38aeb | ||
|
|
fa503fa47f | ||
|
|
38ef6ad51e | ||
|
|
f0d218f7ea | ||
|
|
09c02e8657 | ||
|
|
2ee2a59dff | ||
|
|
96c338735a | ||
|
|
101c572d64 | ||
|
|
9d06652aa9 | ||
|
|
5926e89ce3 | ||
|
|
556b2292c7 | ||
|
|
4be06c85f6 | ||
|
|
868beaf004 | ||
|
|
81bb61359d | ||
|
|
370137e1c3 | ||
|
|
48e6072342 | ||
|
|
b85311c873 | ||
|
|
2f5c625ac7 | ||
|
|
e0c22d6ee0 | ||
|
|
efcc55460b | ||
|
|
e003d4aa01 | ||
|
|
d91341bca3 | ||
|
|
5a42fb5788 | ||
|
|
d914c851ac | ||
|
|
300a5e8906 | ||
|
|
5f3e8c6385 | ||
|
|
8e8a5110ee | ||
|
|
4e44e668f7 | ||
|
|
8d0fefd9e0 | ||
|
|
1e05e7b3ad | ||
|
|
0ea7c14e4a | ||
|
|
0c2e5f5e5c | ||
|
|
9048c7097f | ||
|
|
3d3e2c7f10 | ||
|
|
ce852e26d8 | ||
|
|
460c794f28 | ||
|
|
54914e6082 | ||
|
|
50190e8638 | ||
|
|
184949ff0c | ||
|
|
347d80361d | ||
|
|
be31975f7e | ||
|
|
2acff2b17f | ||
|
|
b9a1752095 | ||
|
|
96f5742511 | ||
|
|
9898024392 | ||
|
|
705844f312 | ||
|
|
1cbc049700 | ||
|
|
ee4096f141 | ||
|
|
b3e99de06c | ||
|
|
0d351179e4 | ||
|
|
8494534c8f | ||
|
|
ae728fe7ec | ||
|
|
b33a06c1e4 | ||
|
|
17a5f29306 |
@@ -57,14 +57,6 @@ 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/
|
||||
@@ -73,23 +65,10 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -115,21 +94,6 @@ linters:
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── 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 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: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,68 +2,6 @@
|
||||
|
||||
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
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
@@ -1026,9 +964,6 @@ 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
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
|
||||
@@ -90,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
@@ -718,23 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if gotOpts.Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
16
cmd/build.go
16
cmd/build.go
@@ -6,7 +6,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -53,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -117,7 +103,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -155,7 +140,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// 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)]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -341,9 +341,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -383,9 +380,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -425,11 +419,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if result.AppSecret != "" {
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -518,10 +507,5 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
// (covering both TAT acquisition and the subsequent probe request).
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// runProbe runs a best-effort credential validation after config init has
|
||||
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||
// deterministic credential-rejection signal; every other outcome returns nil
|
||||
// so that valid configurations and transient/upstream noise never block the
|
||||
// command.
|
||||
//
|
||||
// The function performs up to two HTTP calls in series, bounded by
|
||||
// probeTimeout:
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||
if err != nil {
|
||||
// A typed error from FetchTAT is a deterministic credential rejection
|
||||
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||
// silent and let the command succeed.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
f.probeCalls++
|
||||
f.probeReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
f.probeBody = string(b)
|
||||
}
|
||||
if f.probeHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||
// the caller-supplied RoundTripper.
|
||||
//
|
||||
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||
// touch lands in a temp dir rather than the developer's real config.
|
||||
//
|
||||
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||
// this buffer stays empty as an invariant.
|
||||
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
}
|
||||
return f, errBuf
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("expected nil (silent), got error: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||
// rejection) → silent, exit 0.
|
||||
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500} {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(code, `nope`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(500, `server error`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if rt.probeReq.Method != http.MethodPost {
|
||||
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||
}
|
||||
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||
t.Errorf("probe URL = %s", got)
|
||||
}
|
||||
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||
}
|
||||
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||
}
|
||||
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
312
cmd/root.go
312
cmd/root.go
@@ -18,7 +18,6 @@ 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"
|
||||
@@ -51,6 +50,20 @@ EXAMPLES:
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
@@ -72,15 +85,7 @@ 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)
|
||||
@@ -144,49 +149,29 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
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",
|
||||
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",
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -272,13 +257,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
@@ -291,19 +269,6 @@ 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
|
||||
}
|
||||
@@ -370,204 +335,35 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// 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{},
|
||||
},
|
||||
},
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
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)
|
||||
available := availableSubcommandNames(cmd)
|
||||
suggestions := suggest.Closest(unknown, available, 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(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,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -576,15 +372,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
subs = append(subs, name)
|
||||
}
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
@@ -635,11 +426,6 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
// 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()
|
||||
|
||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api",
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "Bot/User can NOT be out of the chat.",
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ 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"
|
||||
)
|
||||
@@ -269,54 +268,6 @@ 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
|
||||
|
||||
@@ -180,7 +180,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
|
||||
@@ -765,22 +765,3 @@ func TestDetectFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("expected runF to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skill implements the `lark-cli skills` command group, which serves
|
||||
// binary-embedded skill content to AI agents. The package is "skill"; the
|
||||
// user-facing verb is "skills".
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
|
||||
if f.SkillContent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"skill content not embedded in this build")
|
||||
}
|
||||
return skillcontent.New(f.SkillContent), nil
|
||||
}
|
||||
|
||||
type readEnvelope struct {
|
||||
Skill string `json:"skill"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Guidance string `json:"guidance,omitempty"`
|
||||
}
|
||||
|
||||
type listEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []skillcontent.SkillInfo `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type listPathEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []skillcontent.DirEntry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Read embedded skill content (list / read)",
|
||||
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
|
||||
"the CLI binary at build time, so it stays in sync with the CLI version. " +
|
||||
"Machine resources such as assets/ and scripts/ are not embedded.",
|
||||
}
|
||||
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.AddCommand(newListCmd(f), newReadCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [name[/path]]",
|
||||
Short: "List skills, or list one layer under a skill path (like ls)",
|
||||
Example: ` lark-cli skills list # all skills: name, description, version
|
||||
lark-cli skills list lark-doc # one layer under a skill (like ls)
|
||||
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"list takes at most 1 argument: [name[/path]]").
|
||||
WithHint("run 'lark-cli skills list --help'")
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
|
||||
return nil
|
||||
}
|
||||
entries, listed, err := r.ListPath(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
|
||||
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "read <name>[/<path>] [path]",
|
||||
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
|
||||
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
|
||||
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
|
||||
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
|
||||
lark-cli skills read lark-doc --json # JSON envelope`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name, relpath, err := parseReadTarget(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
var pathOut string
|
||||
if relpath == "" {
|
||||
content, err = r.ReadSkill(name)
|
||||
pathOut = "SKILL.md"
|
||||
} else {
|
||||
content, pathOut, err = r.ReadReference(name, relpath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isMain := pathOut == "SKILL.md"
|
||||
if asJSON {
|
||||
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
|
||||
if isMain {
|
||||
env.Guidance = readGuidance(name)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
|
||||
if _, err := f.IOStreams.Out.Write(content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
|
||||
}
|
||||
if isMain {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
|
||||
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
|
||||
func parseReadTarget(args []string) (name, relpath string, err error) {
|
||||
switch len(args) {
|
||||
case 1:
|
||||
name, relpath = skillcontent.SplitArg(args[0])
|
||||
return name, relpath, nil
|
||||
case 2:
|
||||
return args[0], args[1], nil
|
||||
default:
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
|
||||
WithHint("run 'lark-cli skills read --help'")
|
||||
}
|
||||
}
|
||||
|
||||
// readGuidance routes cross-skill "../lark-foo/..." references back through
|
||||
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
|
||||
// relative form must be rewritten.
|
||||
func readGuidance(name string) string {
|
||||
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
|
||||
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
|
||||
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
|
||||
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// calFS is the default single-skill content tree for these tests. The embedded
|
||||
// FS is now injected through the Factory (no package global), so tests pass it
|
||||
// explicitly to run() — nothing is shared, so they are safe under -parallel.
|
||||
func calFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
}
|
||||
}
|
||||
|
||||
// run executes the skills command tree against the given content FS (may be nil
|
||||
// to exercise the not-embedded path) and returns stdout/stderr/err.
|
||||
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
// Isolate CLI config state so tests never read/write the real config dir
|
||||
// (repo convention).
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
|
||||
f.SkillContent = fsys
|
||||
cmd := NewCmdSkill(f)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err = cmd.Execute()
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func TestSkillList(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
// "ok" is an explicit success marker (the list envelope is a typed struct;
|
||||
// no automatic _notice attaches).
|
||||
if !got.OK {
|
||||
t.Error("expected ok=true in list envelope")
|
||||
}
|
||||
if got.Count != 1 || len(got.Skills) != 1 {
|
||||
t.Fatalf("count: got %d", got.Count)
|
||||
}
|
||||
if got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Errorf("name: got %v", got.Skills[0]["name"])
|
||||
}
|
||||
// Top-level list carries version + metadata, not a references list.
|
||||
if _, ok := got.Skills[0]["references"]; ok {
|
||||
t.Error("top-level list must not include references")
|
||||
}
|
||||
if got.Skills[0]["version"] != "1.0.0" {
|
||||
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
|
||||
}
|
||||
if _, ok := got.Skills[0]["metadata"]; !ok {
|
||||
t.Error("expected metadata in list entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListJSONFlagAccepted(t *testing.T) {
|
||||
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
|
||||
// so it stays symmetric with read --json.
|
||||
stdout, _, err := run(t, calFS(), "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("list --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Count != 1 {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPath(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("list <name> error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Path != "lark-calendar" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// One layer under the skill root: SKILL.md (file) + references (dir).
|
||||
if got.Count != 2 || len(got.Entries) != 2 {
|
||||
t.Fatalf("entries: got %+v", got.Entries)
|
||||
}
|
||||
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
|
||||
t.Errorf("entry[0]: got %+v", got.Entries[0])
|
||||
}
|
||||
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
|
||||
t.Errorf("entry[1]: got %+v", got.Entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "no-such-skill")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Fatalf("expected 'unknown skill' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Fatalf("expected 'invalid path' error, got %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListTooManyArgs(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "a", "b")
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
|
||||
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
|
||||
// omitted from the catalog (no blank entry).
|
||||
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
|
||||
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
|
||||
}
|
||||
stdout, _, err := run(t, fsys, "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadRaw(t *testing.T) {
|
||||
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
|
||||
t.Errorf("raw output: got %q", stdout)
|
||||
}
|
||||
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
|
||||
if strings.Contains(stdout, "Tip:") {
|
||||
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
|
||||
}
|
||||
// Guidance goes to stderr: own files via `skills read <name> ...`, and
|
||||
// cross-skill refs routed to `skills read <other-skill> ...` (version-
|
||||
// consistent), not "read directly".
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
|
||||
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
|
||||
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "instead of opening them directly") ||
|
||||
strings.Contains(stderr, "read those directly") {
|
||||
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v", e)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Guidance is a separate field, not merged into content.
|
||||
if got.Guidance == "" {
|
||||
t.Error("expected guidance field for main SKILL.md")
|
||||
}
|
||||
if strings.Contains(got.Content, "Tip:") {
|
||||
t.Error("guidance must not be merged into content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFile(t *testing.T) {
|
||||
// Both the 2-arg and slash forms read the same file, with no guidance tip.
|
||||
for _, args := range [][]string{
|
||||
{"read", "lark-calendar", "references/agenda.md"},
|
||||
{"read", "lark-calendar/references/agenda.md"},
|
||||
} {
|
||||
stdout, stderr, err := run(t, calFS(), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("read %v error: %v", args, err)
|
||||
}
|
||||
if stdout != "# Agenda" {
|
||||
t.Errorf("read %v output: got %q", args, stdout)
|
||||
}
|
||||
// Reference reads carry no guidance on either stream.
|
||||
if strings.Contains(stderr, "Tip:") {
|
||||
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFileJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read file --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Reference reads do not carry the guidance tip.
|
||||
if got.Guidance != "" {
|
||||
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read", "no-such")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadMissingArg(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
|
||||
t.Fatalf("expected arg error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillNilContentFS(t *testing.T) {
|
||||
_, _, err := run(t, nil, "list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SkillContent is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not embedded") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -73,149 +72,6 @@ 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())
|
||||
@@ -308,7 +164,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)
|
||||
@@ -319,61 +175,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,29 +49,18 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g --json":
|
||||
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
@@ -487,10 +476,6 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
@@ -823,11 +808,6 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -880,11 +860,6 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -1029,7 +1004,6 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1068,7 +1042,6 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallManual,
|
||||
@@ -1113,7 +1086,6 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||
@@ -1173,10 +1145,6 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1226,10 +1194,6 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
|
||||
@@ -155,30 +155,7 @@ caller scripts.
|
||||
|
||||
New code should not reach for `ErrBare` unless the command is
|
||||
genuinely a predicate. Anything carrying recoverable error content
|
||||
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
|
||||
partial-failure outcome below.
|
||||
|
||||
### Partial failure (batch / multi-status)
|
||||
|
||||
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
|
||||
many items can finish in a third state, neither full success nor a single
|
||||
error: some items succeeded and some failed. Its primary output is the
|
||||
per-item result, so it does **not** belong in a `stderr` error envelope.
|
||||
|
||||
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
|
||||
|
||||
1. writes the full result to **stdout** as an `ok:false` envelope — the
|
||||
summary and every per-item outcome (succeeded *and* failed) stay
|
||||
machine-readable, exactly as a successful `Out(...)` would carry them,
|
||||
but with `ok` honestly reporting failure; and
|
||||
2. returns `*output.PartialFailureError`, a typed exit signal the
|
||||
dispatcher maps to a non-zero exit code while writing nothing further
|
||||
to `stderr`.
|
||||
|
||||
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
|
||||
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
|
||||
*result*, reported on stdout, that also failed. Consumers branch on
|
||||
`ok == false` and then read `data.summary` / `data.items[]`.
|
||||
belongs in a typed `*errs.XxxError`.
|
||||
|
||||
## Consumers
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
@@ -61,22 +61,8 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
|
||||
// item (RFC 7807 §3.1 extension members).
|
||||
//
|
||||
// The wire key on ValidationError is "params" rather than "invalid_params"
|
||||
// because the enclosing envelope already carries type:"validation", so the
|
||||
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
|
||||
// InvalidParam prefix because, at package level, the name must self-describe.
|
||||
type InvalidParam struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
@@ -136,11 +122,6 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
|
||||
e.Params = append(e.Params, params...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -558,71 +558,6 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidationError_WithParams covers the structured-validation extension:
|
||||
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
|
||||
// and the wire shape nests {name, reason} under "params" (omitted when empty).
|
||||
func TestValidationError_WithParams(t *testing.T) {
|
||||
t.Run("appends and exposes fields", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
if len(e.Params) != 1 {
|
||||
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
|
||||
}
|
||||
if e.Params[0].Name != "a.md" {
|
||||
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
|
||||
}
|
||||
if e.Params[0].Reason != "duplicate" {
|
||||
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
|
||||
if returned != e {
|
||||
t.Errorf("WithParams returned different pointer; want same as receiver")
|
||||
}
|
||||
e.WithParams(
|
||||
errs.InvalidParam{Name: "b.md", Reason: "dup"},
|
||||
errs.InvalidParam{Name: "c.md", Reason: "dup"},
|
||||
)
|
||||
if len(e.Params) != 3 {
|
||||
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParam("--rel-path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"param":"--rel-path"`,
|
||||
`"params":[{"name":"a.md","reason":"duplicate"}]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Params omitted from wire", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), `"params"`) {
|
||||
t.Errorf("empty Params should be omitted from wire; got %s", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
||||
scopes := []string{"docx:document", "im:message:send"}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||
type BoardWhiteboardUpdatedV1Data struct {
|
||||
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||
WhiteboardID string `json:"whiteboard_id"`
|
||||
// OperatorIDs lists the operators that produced this update batch.
|
||||
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||
}
|
||||
|
||||
// OperatorID identifies an operator that produced the whiteboard update,
|
||||
// expressed in the three Lark identity formats.
|
||||
type OperatorID struct {
|
||||
// OpenID is the operator's open_id within the current app.
|
||||
OpenID string `json:"open_id"`
|
||||
// UnionID is the operator's union_id across apps under the same ISV.
|
||||
UnionID string `json:"union_id"`
|
||||
// UserID is the operator's user_id within the tenant.
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// cleanupTimeout bounds how long the unsubscribe call has to finish during
|
||||
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
|
||||
// and returns a cleanup that invokes the matching unsubscribe.
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// recordedCall captures a single APIClient invocation for assertion.
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
// fakeAPIClient is a minimal event.APIClient stub that records calls and
|
||||
// can be configured to fail when the request path matches errOnPath.
|
||||
type fakeAPIClient struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
errOnPath string
|
||||
}
|
||||
|
||||
// CallAPI records the invocation and optionally returns a simulated error
|
||||
// when the path contains the configured errOnPath substring.
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
|
||||
return nil, errors.New("simulated subscribe failure")
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
|
||||
// PreConsume hook fails fast with an actionable error when whiteboard_id
|
||||
// is absent from the params map.
|
||||
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when whiteboard_id missing")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup on error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
// returns an error when the runtime APIClient dependency is missing.
|
||||
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
// failed subscribe call surfaces the error and skips registering a cleanup,
|
||||
// so no spurious unsubscribe is invoked.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{errOnPath: "/subscribe"}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from subscribe call")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup when subscribe fails")
|
||||
}
|
||||
// only the failed subscribe call should have been made; no unsubscribe.
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
|
||||
// happy-path: subscribe is called once with the correct method/path/body,
|
||||
// and the returned cleanup invokes the matching unsubscribe.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatalf("expected non-nil cleanup")
|
||||
}
|
||||
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
|
||||
}
|
||||
got := rt.calls[0]
|
||||
if got.method != "POST" {
|
||||
t.Errorf("subscribe method: got %q, want POST", got.method)
|
||||
}
|
||||
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
|
||||
if got.path != wantSubPath {
|
||||
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
|
||||
}
|
||||
body, _ := got.body.(map[string]string)
|
||||
if body["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if len(rt.calls) != 2 {
|
||||
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
|
||||
}
|
||||
got2 := rt.calls[1]
|
||||
if got2.method != "POST" {
|
||||
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
|
||||
}
|
||||
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
|
||||
if got2.path != wantUnsubPath {
|
||||
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
|
||||
}
|
||||
body2, _ := got2.body.(map[string]string)
|
||||
if body2["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
|
||||
// whiteboard_id values containing reserved URL characters are properly
|
||||
// path-segment encoded so they cannot escape into adjacent path segments.
|
||||
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
|
||||
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(rt.calls))
|
||||
}
|
||||
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
|
||||
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
|
||||
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
|
||||
// required whiteboard_id parameter.
|
||||
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
keys := Keys()
|
||||
for _, k := range keys {
|
||||
if k.Key == eventTypeWhiteboardUpdated {
|
||||
if k.PreConsume == nil {
|
||||
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
if len(k.Params) == 0 {
|
||||
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
var found bool
|
||||
for _, p := range k.Params {
|
||||
if p.Name == "whiteboard_id" && p.Required {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
// 确保 event.APIClient 接口与本测试 mock 一致。
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package whiteboard registers Board-domain EventKeys.
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||
|
||||
// Keys returns all Board-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeWhiteboardUpdated,
|
||||
DisplayName: "Whiteboard updated",
|
||||
Description: "Pushed when the whiteboard content is updated.",
|
||||
EventType: eventTypeWhiteboardUpdated,
|
||||
Params: []event.ParamDef{
|
||||
{
|
||||
Name: "whiteboard_id",
|
||||
Type: event.ParamString,
|
||||
Required: true,
|
||||
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||
},
|
||||
},
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||
},
|
||||
},
|
||||
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||
},
|
||||
}
|
||||
}
|
||||
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 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -44,8 +43,6 @@ type Factory struct {
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
|
||||
@@ -102,7 +102,7 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -75,6 +75,8 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set("x-tt-env", "ppe_moa_canvas")
|
||||
h.Set("x-use-ppe", "1")
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -164,9 +166,42 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": acct.AppID,
|
||||
"app_secret": acct.AppSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenResult{Token: token}, nil
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
|
||||
}
|
||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||
type stubRoundTripper struct {
|
||||
gotReq *http.Request
|
||||
gotBody string
|
||||
respCode int
|
||||
respBody string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
s.gotReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
s.gotBody = string(b)
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: s.respCode,
|
||||
Body: io.NopCloser(strings.NewReader(s.respBody)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d: expected error", code)
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
|
||||
sentinel := errors.New("network down")
|
||||
rt := &stubRoundTripper{err: sentinel}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("transport error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("error chain missing sentinel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("parse error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rt.gotReq.URL.String(); got != tc.wantURL {
|
||||
t.Errorf("url = %s, want %s", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ContextCanceled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rt := &urlRewriteRT{base: srv.URL}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-canceled
|
||||
|
||||
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for canceled context")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("error chain missing context.Canceled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// urlRewriteRT forwards requests to a fixed base URL (test server).
|
||||
type urlRewriteRT struct{ base string }
|
||||
|
||||
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
newURL := r.base + req.URL.Path
|
||||
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req2.Header = req.Header
|
||||
return http.DefaultTransport.RoundTrip(req2)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// 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() }
|
||||
@@ -1,58 +0,0 @@
|
||||
// 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,18 +92,6 @@ 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:
|
||||
@@ -141,11 +129,6 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
// 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
|
||||
@@ -230,10 +213,6 @@ 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}
|
||||
}
|
||||
@@ -252,24 +231,6 @@ func ConfigHint(subtype errs.Subtype) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||
func APIHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeConflict:
|
||||
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||
case errs.SubtypeCrossTenant:
|
||||
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 ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
@@ -278,10 +239,6 @@ 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,
|
||||
@@ -390,32 +347,6 @@ 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,111 +220,6 @@ 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) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// 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") }
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// driveCodeMeta holds drive/docs-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 driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
|
||||
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
|
||||
// Each case traces to repo evidence (see codemeta_drive.go comments).
|
||||
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 1061044: upload with a nonexistent parent folder token. The drive E2E
|
||||
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
|
||||
// producer via a nonexistent parent folder → referenced resource missing.
|
||||
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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") }
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is stable across minutes endpoints are registered;
|
||||
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
|
||||
// Command-specific messages, hints, and subtypes are layered on top via
|
||||
// per-command enrichment.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var minutesCodeMeta = map[int]CodeMeta{
|
||||
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
|
||||
@@ -70,12 +70,6 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
|
||||
if got, ok := LookupCodeMeta(2091001); ok {
|
||||
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes (e.g. 124002 "recording still generating", which has no
|
||||
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
|
||||
// per-command enrichment for a retry hint.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var vcCodeMeta = map[int]CodeMeta{
|
||||
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
|
||||
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(vcCodeMeta, "vc") }
|
||||
@@ -170,28 +170,6 @@ func ErrBare(code int) *ExitError {
|
||||
return &ExitError{Code: code}
|
||||
}
|
||||
|
||||
// PartialFailureError is the exit signal for a batch / multi-status command that
|
||||
// has already written an ok:false result envelope to stdout. The per-item
|
||||
// outcomes are the primary, machine-readable output and live on stdout, so the
|
||||
// dispatcher sets only the exit code and writes nothing to stderr.
|
||||
//
|
||||
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
|
||||
// so the predicate contract stays narrow, and from a typed *errs.XxxError
|
||||
// (which owns the stderr error envelope): a partial failure is a result, not an
|
||||
// error envelope.
|
||||
type PartialFailureError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func (e *PartialFailureError) Error() string {
|
||||
return fmt.Sprintf("partial failure (exit %d)", e.Code)
|
||||
}
|
||||
|
||||
// PartialFailure builds the partial-failure exit signal with the given code.
|
||||
func PartialFailure(code int) *PartialFailureError {
|
||||
return &PartialFailureError{Code: code}
|
||||
}
|
||||
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
|
||||
@@ -61,10 +61,6 @@ func ExitCodeOf(err error) int {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var pfErr *PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
|
||||
@@ -231,9 +231,14 @@ func TestLoadAutoApproveSet(t *testing.T) {
|
||||
t.Fatal("expected non-empty auto-approve set")
|
||||
}
|
||||
|
||||
// From scope_priorities.json recommend=="true"
|
||||
// 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
|
||||
if !aaSet["sheets:spreadsheet:read"] {
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
|
||||
}
|
||||
|
||||
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
||||
@@ -252,10 +257,16 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// 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))
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,9 +277,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAutoApproveScope(t *testing.T) {
|
||||
// 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")
|
||||
// Known auto-approve scope (in allow list)
|
||||
if !IsAutoApproveScope("calendar:calendar.event:create") {
|
||||
t.Error("expected calendar:calendar.event:create to be auto-approve")
|
||||
}
|
||||
|
||||
// Completely unknown scope
|
||||
@@ -279,8 +290,9 @@ func TestIsAutoApproveScope(t *testing.T) {
|
||||
|
||||
func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
scopes := []string{
|
||||
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
"calendar:calendar.event:create", // auto-approve (in allow list)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
"sheets:spreadsheet:read", // auto-approve (in allow list)
|
||||
}
|
||||
|
||||
result := FilterAutoApproveScopes(scopes)
|
||||
@@ -288,10 +300,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
t.Fatal("expected at least 1 auto-approve scope in result")
|
||||
}
|
||||
|
||||
// Check that sheets:spreadsheet:read is included
|
||||
// Check that calendar:calendar.event:create is included
|
||||
found := false
|
||||
for _, s := range result {
|
||||
if s == "sheets:spreadsheet:read" {
|
||||
if s == "calendar:calendar.event:create" {
|
||||
found = true
|
||||
}
|
||||
// Ensure unknown scopes are not included
|
||||
@@ -300,7 +312,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected sheets:spreadsheet:read in result")
|
||||
t.Error("expected calendar:calendar.event:create in result")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,25 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"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"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -10,13 +10,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -40,15 +37,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
skillsIndexMaxBodySize = 1 << 20
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -92,7 +83,6 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
@@ -163,53 +153,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
}
|
||||
|
||||
r := &NpmResult{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
|
||||
client := transport.NewHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
|
||||
return r
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
|
||||
if _, err := io.Copy(&r.Stdout, limited); err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
if r.Stdout.Len() > skillsIndexMaxBodySize {
|
||||
r.Stdout.Reset()
|
||||
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
|
||||
return r
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
@@ -222,10 +165,6 @@ 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 {
|
||||
|
||||
@@ -4,18 +4,12 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -194,13 +188,6 @@ 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 {
|
||||
@@ -238,113 +225,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
|
||||
}
|
||||
if result.Stdout.Len() != 0 {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
oldTimeout := skillsIndexFetchTimeout
|
||||
officialSkillsIndexURL = server.URL
|
||||
skillsIndexFetchTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
officialSkillsIndexURL = oldURL
|
||||
skillsIndexFetchTimeout = oldTimeout
|
||||
})
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
var netErr net.Error
|
||||
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
|
||||
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
|
||||
return r
|
||||
}}).ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if !strings.Contains(result.Stdout.String(), "override-skill") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillcontent reads embedded skill content from an injected fs.FS
|
||||
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
|
||||
// fed straight back into `read`.
|
||||
type DirEntry struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
func (r *Reader) List() ([]SkillInfo, error) {
|
||||
entries, err := fs.ReadDir(r.fsys, ".")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
|
||||
}
|
||||
out := make([]SkillInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Skip dirs that aren't real skills (no SKILL.md).
|
||||
if info, ok := r.skillInfo(e.Name()); ok {
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
desc, version, metadata := parseFrontmatter(data)
|
||||
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
|
||||
}
|
||||
|
||||
// ListPath lists one directory layer (no recursion) under "<name>" or
|
||||
// "<name>/<sub>", returning the entries and the cleaned path listed.
|
||||
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
|
||||
name, sub := SplitArg(arg)
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir := name
|
||||
if sub != "" {
|
||||
cleaned, err := cleanSubPath(sub)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir = name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q not found in skill %q", sub, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
|
||||
}
|
||||
}
|
||||
entries, err := fs.ReadDir(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, dir, nil
|
||||
}
|
||||
|
||||
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
|
||||
// separator is a bare skill name (rest "").
|
||||
func SplitArg(arg string) (name, rest string) {
|
||||
name, rest, _ = strings.Cut(arg, "/")
|
||||
return name, rest
|
||||
}
|
||||
|
||||
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
|
||||
// unparseable frontmatter yields ("", "", nil), never an error.
|
||||
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
|
||||
lines := strings.Split(string(skillMD), "\n")
|
||||
if strings.TrimRight(lines[0], "\r") != "---" {
|
||||
return "", "", nil
|
||||
}
|
||||
block := make([]string, 0, len(lines))
|
||||
closed := false
|
||||
for _, ln := range lines[1:] {
|
||||
if strings.TrimRight(ln, "\r") == "---" {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
block = append(block, ln)
|
||||
}
|
||||
if !closed {
|
||||
return "", "", nil
|
||||
}
|
||||
var fm struct {
|
||||
Description string `yaml:"description"`
|
||||
Version string `yaml:"version"`
|
||||
Metadata map[string]any `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
return fm.Description, fm.Version, fm.Metadata
|
||||
}
|
||||
|
||||
func (r *Reader) ReadSkill(name string) ([]byte, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *Reader) ensureSkill(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
info, err := fs.Stat(r.fsys, name)
|
||||
if err != nil || !info.IsDir() {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknownSkill(name string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
|
||||
WithHint("run 'lark-cli skills list' to see available skills")
|
||||
}
|
||||
|
||||
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
|
||||
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
|
||||
func cleanSubPath(relpath string) (string, error) {
|
||||
cleaned := path.Clean(relpath)
|
||||
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
|
||||
// survives; reject it explicitly alongside "../".
|
||||
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
|
||||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid path %q: must be a relative path without '..'", relpath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
|
||||
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cleaned, err := cleanSubPath(relpath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
full := name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q not found in skill %q", relpath, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q is a directory, not a file", relpath)
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, cleaned, nil
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
"lark-calendar/references/create.md": {Data: []byte("# Create")},
|
||||
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
|
||||
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
|
||||
"lark-im/references/send.md": {Data: []byte("# Send")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
r := New(testFS())
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("got %d skills, want 2", len(skills))
|
||||
}
|
||||
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
|
||||
t.Fatalf("skills not sorted by name: %v", skills)
|
||||
}
|
||||
if skills[0].Description != "Calendar skill" {
|
||||
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
|
||||
}
|
||||
// version is the frontmatter `version:` field, passed through for drift checks.
|
||||
if skills[0].Version != "1.0.0" {
|
||||
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
|
||||
}
|
||||
// metadata is the frontmatter `metadata:` block, passed through verbatim.
|
||||
if skills[0].Metadata == nil {
|
||||
t.Fatal("expected metadata for lark-calendar")
|
||||
}
|
||||
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
|
||||
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
|
||||
}
|
||||
// No frontmatter → empty description and nil metadata (omitted from JSON).
|
||||
if skills[1].Description != "" {
|
||||
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
|
||||
}
|
||||
if skills[1].Metadata != nil {
|
||||
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
|
||||
}
|
||||
if skills[1].Version != "" {
|
||||
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPath(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
// Skill root: direct children only (one layer), each path skill-prefixed.
|
||||
entries, listed, err := r.ListPath("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath root error: %v", err)
|
||||
}
|
||||
if listed != "lark-calendar" {
|
||||
t.Errorf("listed path: got %q", listed)
|
||||
}
|
||||
want := map[string]bool{ // path → isDir
|
||||
"lark-calendar/SKILL.md": false,
|
||||
"lark-calendar/references": true,
|
||||
"lark-calendar/assets": true,
|
||||
}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
|
||||
}
|
||||
for _, e := range entries {
|
||||
isDir, ok := want[e.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected entry %q", e.Path)
|
||||
continue
|
||||
}
|
||||
if e.IsDir != isDir {
|
||||
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
|
||||
}
|
||||
}
|
||||
// Entries are sorted by path.
|
||||
if entries[0].Path != "lark-calendar/SKILL.md" {
|
||||
t.Errorf("entries not sorted: %v", entries)
|
||||
}
|
||||
|
||||
// Subdirectory: one layer under <name>/<subpath>.
|
||||
subEntries, subListed, err := r.ListPath("lark-calendar/references")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath subdir error: %v", err)
|
||||
}
|
||||
if subListed != "lark-calendar/references" {
|
||||
t.Errorf("listed subpath: got %q", subListed)
|
||||
}
|
||||
if len(subEntries) != 2 ||
|
||||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
|
||||
subEntries[1].Path != "lark-calendar/references/create.md" {
|
||||
t.Errorf("subdir entries: got %v", subEntries)
|
||||
}
|
||||
|
||||
// Unknown skill → typed validation error.
|
||||
if _, _, err := r.ListPath("no-such-skill"); err == nil {
|
||||
t.Error("expected error for unknown skill")
|
||||
} else {
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Path that points at a file (not a dir) → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
|
||||
t.Error("expected error listing a file")
|
||||
} else if !strings.Contains(err.Error(), "is a file") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Nonexistent subpath → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Traversal in the subpath is rejected, no listing leaked.
|
||||
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
|
||||
entries, _, err := r.ListPath(bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if entries != nil {
|
||||
t.Errorf("entries leaked for %q: %v", bad, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkill(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, err := r.ReadSkill("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSkill error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
|
||||
t.Errorf("unexpected content: %q", string(data))
|
||||
}
|
||||
|
||||
_, err = r.ReadSkill("no-such-skill")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown skill")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
|
||||
t.Errorf("message: got %q", verr.Message)
|
||||
}
|
||||
|
||||
if _, err := r.ReadSkill("../etc"); err == nil {
|
||||
t.Error("expected error for name with separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadReference(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadReference error: %v", err)
|
||||
}
|
||||
if string(data) != "# Agenda" {
|
||||
t.Errorf("content: got %q", string(data))
|
||||
}
|
||||
if cleaned != "references/agenda.md" {
|
||||
t.Errorf("cleaned path: got %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
|
||||
t.Error("expected directory error")
|
||||
} else if !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
|
||||
data, _, err := r.ReadReference("lark-calendar", bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if data != nil {
|
||||
t.Errorf("content leaked for %q: %q", bad, string(data))
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected validation error for %q, got %T", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDesc string
|
||||
wantVer string
|
||||
wantHasMeta bool
|
||||
}{
|
||||
{
|
||||
name: "description, version and metadata",
|
||||
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
|
||||
wantDesc: "My skill",
|
||||
wantVer: "2.1.0",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "description only, no metadata",
|
||||
input: "---\ndescription: Plain\n---\nbody\n",
|
||||
wantDesc: "Plain",
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
input: "no frontmatter here\n",
|
||||
},
|
||||
{
|
||||
name: "unclosed frontmatter",
|
||||
input: "---\ndescription: Never closed\n",
|
||||
},
|
||||
{
|
||||
name: "malformed YAML inside frontmatter",
|
||||
input: "---\n: bad: yaml: [\n---\nbody\n",
|
||||
},
|
||||
{
|
||||
name: "CRLF line endings",
|
||||
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
|
||||
wantDesc: "CRLF skill",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, ver, meta := parseFrontmatter([]byte(tc.input))
|
||||
if desc != tc.wantDesc {
|
||||
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
if ver != tc.wantVer {
|
||||
t.Errorf("version = %q, want %q", ver, tc.wantVer)
|
||||
}
|
||||
if (meta != nil) != tc.wantHasMeta {
|
||||
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkillMissingFile(t *testing.T) {
|
||||
// Use a separate MapFS so testFS() (and TestList) are unaffected.
|
||||
emptyFS := fstest.MapFS{
|
||||
"lark-empty/references/x.md": {Data: []byte("# X")},
|
||||
}
|
||||
r := New(emptyFS)
|
||||
_, err := r.ReadSkill("lark-empty")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SKILL.md is absent")
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(err, &ierr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -58,52 +57,6 @@ func ParseSkillsList(text string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseGlobalSkillsJSON(text string) []string {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
|
||||
type officialSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type officialIndex struct {
|
||||
Skills []officialSkill `json:"skills"`
|
||||
}
|
||||
|
||||
var index officialIndex
|
||||
if err := json.Unmarshal([]byte(text), &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range index.Skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
|
||||
return sortedKeys(seen), nil
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -124,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "Agents:") {
|
||||
continue
|
||||
}
|
||||
|
||||
if isGlobalSkillsSectionHeader(trimmed) {
|
||||
// Skip indented lines (Agents: ...)
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -141,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
candidate := parts[0]
|
||||
|
||||
// Validate and add
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(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{}
|
||||
@@ -184,7 +131,8 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
|
||||
if len(parts) > 0 {
|
||||
candidate := parts[0]
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
// Check if it's a valid official skill name
|
||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
@@ -246,9 +194,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
@@ -282,15 +228,21 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
local := []string{}
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult != nil && localResult.Err == nil {
|
||||
local = ParseSkillsList(localResult.Stdout.String())
|
||||
}
|
||||
|
||||
// --- Step 3: Read previous state ---
|
||||
@@ -318,10 +270,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
@@ -346,58 +294,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
indexResult := runner.ListOfficialSkillsIndex()
|
||||
if indexResult == nil || indexResult.Err != nil {
|
||||
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
|
||||
} else {
|
||||
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
|
||||
if err != nil {
|
||||
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
|
||||
} else if len(official) > 0 {
|
||||
return official, "", true
|
||||
} else {
|
||||
reasons = append(reasons, "official skills index contains no skills")
|
||||
}
|
||||
}
|
||||
|
||||
officialResult := runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
|
||||
return nil, strings.Join(reasons, "; "), false
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
if len(official) > 0 {
|
||||
return official, "", true
|
||||
}
|
||||
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
|
||||
} else {
|
||||
reasons = append(reasons, "official skills list returned no skills")
|
||||
}
|
||||
return nil, strings.Join(reasons, "; "), false
|
||||
}
|
||||
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
textResult := runner.ListGlobalSkills()
|
||||
if textResult != nil && textResult.Err == nil {
|
||||
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
|
||||
// when incremental sync is not possible. On success it writes a state file so that
|
||||
// subsequent syncs can use incremental mode. When official is non-nil the state
|
||||
|
||||
@@ -30,19 +30,6 @@ lark-cli-harness:dev@0.1.0
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
input := `Available Skills
|
||||
│ lark-calendar
|
||||
│ official-shared
|
||||
│ bad/name
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "official-shared"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
@@ -80,86 +67,6 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
General
|
||||
lark-apps ~/.agents/skills/lark-apps
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-apps", "lark-base"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSON(t *testing.T) {
|
||||
input := `[
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
|
||||
{"name":""},
|
||||
{"name":" "},
|
||||
{"name":"bad skill"}
|
||||
]`
|
||||
got := ParseGlobalSkillsJSON(input)
|
||||
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`[]`,
|
||||
} {
|
||||
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
|
||||
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
|
||||
input := `{
|
||||
"skills": [
|
||||
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
|
||||
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
|
||||
{"name":" lark-base ","description":"Base","files":[]},
|
||||
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
|
||||
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
|
||||
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
|
||||
{"name":"","description":"empty","files":["SKILL.md"]}
|
||||
]
|
||||
}`
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
|
||||
}
|
||||
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`[{"name":"lark-calendar"}]`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`{"skills":[]}`,
|
||||
`{"skills":[{"name":"bad skill"}]}`,
|
||||
} {
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err == nil && len(got) != 0 {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -206,22 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialIndexOut string
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialIndexErr error
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
@@ -235,19 +134,6 @@ func officialSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func officialSkillsIndexOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"skills":[`)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Global Skills\n\n")
|
||||
@@ -260,45 +146,14 @@ func globalSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsJSONOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
|
||||
f.listedIndex++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialIndexOut)
|
||||
r.Err = f.officialIndexErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
f.listedOfficial++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
|
||||
f.listedGlobalJSON++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalJSONOut)
|
||||
r.Err = f.globalJSONErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
f.listedGlobalText++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
@@ -331,10 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
@@ -346,12 +199,6 @@ 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 {
|
||||
@@ -366,119 +213,12 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
if runner.listedIndex != 1 {
|
||||
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||
}
|
||||
if runner.listedOfficial != 0 {
|
||||
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -506,9 +246,8 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -523,123 +262,58 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
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_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
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.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)
|
||||
}
|
||||
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
|
||||
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
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 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(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",
|
||||
}
|
||||
|
||||
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 != "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.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -667,12 +341,10 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -701,9 +373,8 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -719,9 +390,8 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -744,9 +414,8 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -770,12 +439,10 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -796,12 +463,10 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -821,9 +486,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -840,10 +504,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("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" {
|
||||
|
||||
@@ -73,7 +73,18 @@ func redactProxyURL(raw string) string {
|
||||
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
|
||||
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
func WarnIfProxied(w io.Writer) {
|
||||
//
|
||||
// The warning is suppressed entirely when interactive is false — i.e. stdin is
|
||||
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
|
||||
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
|
||||
// stray stderr warning then corrupts the parsed payload. Suppressing in the
|
||||
// non-interactive case keeps machine-consumed output clean, while human
|
||||
// interactive sessions still get the security notice. Passing interactive=false
|
||||
// does not consume the once guard, so a later interactive call can still warn.
|
||||
func WarnIfProxied(w io.Writer, interactive bool) {
|
||||
if !interactive {
|
||||
return
|
||||
}
|
||||
proxyWarningOnce.Do(func() {
|
||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||
// Shared), so its warning and disable instructions take precedence.
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
@@ -70,13 +70,30 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
|
||||
// where stdin is not a TTY. The proxy warning must be suppressed so callers
|
||||
// that parse stdout as JSON — often merging streams with `2>&1` — are not
|
||||
// corrupted by a stray stderr line.
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, false)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -88,7 +105,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
||||
@@ -105,10 +122,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
first := buf.String()
|
||||
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
second := buf.String()
|
||||
|
||||
if first == "" {
|
||||
@@ -137,7 +154,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "127.0.0.1:3128") {
|
||||
@@ -169,7 +186,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "custom CA") {
|
||||
@@ -195,7 +212,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "s3cret") {
|
||||
@@ -243,7 +260,7 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
WarnIfProxied(&buf, true)
|
||||
|
||||
out := buf.String()
|
||||
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// 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/calendar/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
|
||||
// to the typed errs.* taxonomy. On these paths, constructing a legacy
|
||||
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
|
||||
// name (default or alias) this path is bound to in each file, so an aliased
|
||||
// import cannot bypass the check.
|
||||
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
|
||||
|
||||
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
|
||||
// output.ExitError / output.ErrDetail composite literals on migrated paths.
|
||||
// forbidigo can ban identifiers but not composite literals, so this AST rule
|
||||
// covers the gap left after a path is migrated to typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
|
||||
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
|
||||
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
|
||||
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(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
|
||||
}
|
||||
// Resolve the local name(s) bound to the legacy output import path. A file
|
||||
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
|
||||
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
|
||||
// as bare unqualified idents.
|
||||
localNames, dotImported := resolveLegacyOutputNames(file)
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_envelope_literal",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(lit.Pos()).Line,
|
||||
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
|
||||
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
|
||||
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// isMigratedEnvelopePath reports whether path falls under any migrated path
|
||||
// prefix in migratedEnvelopePaths.
|
||||
func isMigratedEnvelopePath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedEnvelopePaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveLegacyOutputNames walks the file's import declarations and returns the
|
||||
// set of local names bound to legacyOutputImportPath, plus whether the path was
|
||||
// dot-imported. Default imports bind the package's own name ("output"); aliased
|
||||
// imports bind the alias; dot-imports bind names into the file scope.
|
||||
func resolveLegacyOutputNames(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 != legacyOutputImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
// Default import: local name is the package name "output".
|
||||
names["output"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
// Blank import cannot reference the types; ignore.
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
|
||||
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
|
||||
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
|
||||
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
|
||||
// local names for the legacy output import, and — when the package was
|
||||
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
|
||||
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
|
||||
if sel, ok := expr.(*ast.SelectorExpr); ok {
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok || sel.Sel == nil {
|
||||
return "", false
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return "", false
|
||||
}
|
||||
return matchLegacyEnvelopeName(sel.Sel.Name)
|
||||
}
|
||||
if dotImported {
|
||||
if ident, ok := expr.(*ast.Ident); ok {
|
||||
return matchLegacyEnvelopeName(ident.Name)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// matchLegacyEnvelopeName returns the name when it is one of the legacy
|
||||
// envelope type names.
|
||||
func matchLegacyEnvelopeName(name string) (string, bool) {
|
||||
switch name {
|
||||
case "ExitError", "ErrDetail":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy
|
||||
// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on
|
||||
// migrated paths. Those helpers route failures through common.HandleApiResult /
|
||||
// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and
|
||||
// downgrade an already-typed network / auth boundary error into an API error.
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call the domain's typed API wrapper or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper
|
||||
// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(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
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel == nil {
|
||||
return true
|
||||
}
|
||||
if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_runtime_api_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's
|
||||
// legacy auto-classifying API helper methods.
|
||||
func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
switch name {
|
||||
case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -593,486 +593,3 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
|
||||
t.Errorf("message must name the offending call: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
|
||||
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() *output.ErrDetail {
|
||||
return &output.ErrDetail{Code: 7}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ErrDetail") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
|
||||
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
|
||||
// Same offending literal, but outside the migrated path set → not flagged.
|
||||
src := `package other
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("_test.go file should be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
|
||||
// import of internal/output cannot bypass the rule: the qualifier is resolved
|
||||
// from the import declaration, not matched against the literal string "output".
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &legacy.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
|
||||
// regression where resolving by import path accidentally drops the default
|
||||
// (non-aliased) `output` case.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
|
||||
// a CallExpr, not a composite literal — even under an alias it must not fire.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return legacy.ErrBare(legacy.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
|
||||
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
|
||||
// the composite literal.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
|
||||
// selector on an unrelated package (not the legacy output import path) must not
|
||||
// trigger a false positive.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "example.com/other/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("unrelated package selector must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
|
||||
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed wrapper call must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
|
||||
// RawAPI / DoAPI return the raw response for the caller to classify and do
|
||||
// not emit a legacy envelope — they are not banned.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
|
||||
_, err := runtime.DoAPI(nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
|
||||
if len(v) != 0 {
|
||||
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",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
t.Run(path+"_"+helper, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.` + helper + `()
|
||||
}
|
||||
`
|
||||
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 contact
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,6 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckNoRegistrar(rel, string(src))...)
|
||||
all = append(all, CheckAdHocSubtype(rel, string(src))...)
|
||||
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.48",
|
||||
"version": "1.0.45",
|
||||
"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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+advperm-enable", "--base-token", "app_x"}
|
||||
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
|
||||
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
|
||||
@@ -224,5 +226,7 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
|
||||
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
|
||||
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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)
|
||||
},
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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)
|
||||
},
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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)
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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)
|
||||
},
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// 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 baseFlagErrorf("--dsl invalid JSON: %v", err)
|
||||
return common.FlagErrorf("--dsl invalid JSON: %v", err)
|
||||
}
|
||||
_, hasDim := dsl["dimensions"]
|
||||
_, hasMeas := dsl["measures"]
|
||||
if !hasDim && !hasMeas {
|
||||
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -32,29 +32,6 @@ 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()
|
||||
|
||||
@@ -94,29 +71,6 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
filteredListRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"sort-json": `[{"field":"Due","desc":true}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordList(ctx, filteredListRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records",
|
||||
"limit=20",
|
||||
"filter=%7B",
|
||||
"Status",
|
||||
"Todo",
|
||||
"sort=%5B",
|
||||
"Due",
|
||||
)
|
||||
|
||||
commaFieldRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-id": {"A,B", "C"}},
|
||||
@@ -145,33 +99,6 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
`"limit":500`,
|
||||
)
|
||||
|
||||
searchFlagRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"keyword": "Alice",
|
||||
"view-id": "viw_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`,
|
||||
"sort-json": `[{"field":"Updated At","desc":true}]`,
|
||||
},
|
||||
map[string][]string{
|
||||
"search-field": {"Name"},
|
||||
"field-id": {"Name", "Status"},
|
||||
},
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordSearch(ctx, searchFlagRT),
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
|
||||
`"keyword":"Alice"`,
|
||||
`"search_fields":["Name"]`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`,
|
||||
`"sort":[{"desc":true,"field":"Updated At"}]`,
|
||||
)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
|
||||
@@ -4,13 +4,9 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -28,198 +24,76 @@ 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, baseAPIBoundaryError(err, action)
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code == 0 {
|
||||
return resultMap["data"], nil
|
||||
}
|
||||
|
||||
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
// 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...)
|
||||
larkCode := int(code)
|
||||
msg := extractDataErrorMessage(resultMap)
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
msg, _ = resultMap["msg"].(string)
|
||||
}
|
||||
if cause := firstErrorArg(args); cause != nil {
|
||||
err = err.WithCause(cause)
|
||||
|
||||
detail := extractErrorDetail(resultMap)
|
||||
apiErr := output.ErrAPI(larkCode, msg, detail)
|
||||
hint := extractErrorHint(resultMap)
|
||||
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
|
||||
apiErr.Detail.Hint = hint
|
||||
}
|
||||
return err
|
||||
if apiErr.Detail != nil {
|
||||
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
|
||||
}
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
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:]
|
||||
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
|
||||
detailMap, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return params
|
||||
for key, value := range detailMap {
|
||||
if value == nil {
|
||||
delete(detailMap, key)
|
||||
}
|
||||
}
|
||||
if len(detailMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
return detailMap
|
||||
}
|
||||
|
||||
func firstErrorArg(args []any) error {
|
||||
for _, arg := range args {
|
||||
if err, ok := arg.(error); ok {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
|
||||
if src == nil {
|
||||
return nil, false
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
|
||||
value, ok := src[key]
|
||||
if !ok {
|
||||
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)
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return nil, false
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
|
||||
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,15 +4,30 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -38,12 +53,9 @@ 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 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
|
||||
@@ -51,7 +63,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
|
||||
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 800010407,
|
||||
"msg": "cell value invalid",
|
||||
@@ -75,27 +87,55 @@ func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if p.Code != 800010407 {
|
||||
t.Fatalf("code=%d", p.Code)
|
||||
|
||||
errDetail := exitErr.Detail
|
||||
if errDetail.Code != 800010407 {
|
||||
t.Fatalf("code=%d", errDetail.Code)
|
||||
}
|
||||
if p.Message != "The cell value does not match the expected input shape." {
|
||||
t.Fatalf("message=%q", p.Message)
|
||||
if errDetail.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", errDetail.Hint)
|
||||
}
|
||||
if p.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", p.Hint)
|
||||
detail, _ := errDetail.Detail.(map[string]interface{})
|
||||
if detail == nil {
|
||||
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
|
||||
}
|
||||
if p.LogID != "20260508160000000000000000000000" {
|
||||
t.Fatalf("logID=%q", p.LogID)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
|
||||
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 99991676,
|
||||
"code": output.LarkErrTokenNoPermission,
|
||||
"msg": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
@@ -106,15 +146,15 @@ func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if p.Code != 99991676 {
|
||||
t.Fatalf("code=%d", p.Code)
|
||||
if exitErr.Detail.Message != "Permission denied [99991676]" {
|
||||
t.Fatalf("message=%q", exitErr.Detail.Message)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,91 +167,16 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
|
||||
attachBaseErrorLogID(result, "20260508170000000000000000000000")
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
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)
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["logid"] != "20260508170000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +18,6 @@ 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"
|
||||
@@ -412,167 +411,6 @@ 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{
|
||||
@@ -931,10 +769,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,
|
||||
RawBody: []byte("404 page not found"),
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables",
|
||||
Status: 404,
|
||||
Body: "404 page not found",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
@@ -1136,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
@@ -1152,121 +990,12 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(body, `"keyword":"Created"`) ||
|
||||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
|
||||
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
|
||||
!strings.Contains(body, `"offset":0`) ||
|
||||
!strings.Contains(body, `"limit":2`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with flag filter sort and projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Status"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_status"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "Created",
|
||||
"--search-field", "Title",
|
||||
"--field-id", "Title",
|
||||
"--field-id", "Status",
|
||||
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
|
||||
"--limit", "20",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
|
||||
}
|
||||
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
|
||||
t.Fatalf("captured body=%#v", body)
|
||||
}
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["logic"] != "and" {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
conditions := filter["conditions"].([]interface{})
|
||||
if len(conditions) != 2 {
|
||||
t.Fatalf("conditions=%#v", conditions)
|
||||
}
|
||||
sortConfig := body["sort"].([]interface{})
|
||||
if len(sortConfig) != 2 {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with filter json file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
tmp := t.TempDir()
|
||||
withBaseWorkingDir(t, tmp)
|
||||
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
|
||||
t.Fatalf("write filter err=%v", err)
|
||||
}
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "A",
|
||||
"--search-field", "Title",
|
||||
"--filter-json", "@filter.json",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
body := string(searchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -2153,9 +1882,6 @@ 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) {
|
||||
@@ -2325,23 +2051,6 @@ 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{
|
||||
@@ -2538,37 +2247,21 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var partialErr *output.PartialFailureError
|
||||
if !errors.As(err, &partialErr) {
|
||||
t.Fatalf("expected partial failure error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
}
|
||||
|
||||
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())
|
||||
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)
|
||||
}
|
||||
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 detail["log_id"] != "202605270001" {
|
||||
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
|
||||
}
|
||||
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 baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
||||
|
||||
var questionIds []string
|
||||
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
|
||||
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err)
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--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 baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "PATCH",
|
||||
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
)
|
||||
@@ -61,31 +62,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
|
||||
attachments, hasAttachments := raw["attachments"]
|
||||
|
||||
if !hasAttachments && fields == nil {
|
||||
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
}
|
||||
|
||||
if hasAttachments {
|
||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||
if runtime.Str("base-token") == "" {
|
||||
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
}
|
||||
|
||||
attMap, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
return common.FlagErrorf("--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 baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
for i, item := range paths {
|
||||
if _, ok := item.(string); !ok {
|
||||
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,21 +111,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, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
return nil, nil, common.FlagErrorf(`--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, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
return nil, nil, common.FlagErrorf("--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, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
}
|
||||
}
|
||||
if len(filePaths) > 0 {
|
||||
@@ -194,33 +195,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
}
|
||||
|
||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||
allPaths := collectUniquePaths(attachmentMap)
|
||||
if len(allPaths) == 0 {
|
||||
return baseFlagErrorf("attachments in --json contains no valid file paths")
|
||||
return common.FlagErrorf("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 baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err)
|
||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
|
||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return baseValidationErrorf("attachment file %s is not a regular file", filePath)
|
||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
||||
}
|
||||
sizeMap[filePath] = fileInfo.Size()
|
||||
}
|
||||
@@ -327,7 +328,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, baseUploadAttachmentError(filePath, err)
|
||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -18,14 +17,6 @@ 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"`
|
||||
@@ -33,17 +24,23 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &resp); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err)
|
||||
return fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
return baseRoleAPIError(runtime, result, action)
|
||||
return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
|
||||
@@ -78,8 +75,7 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
}
|
||||
if codeInt != 0 {
|
||||
msg, _ := m["message"].(string)
|
||||
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
|
||||
return baseRoleAPIError(runtime, result, action)
|
||||
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
|
||||
}
|
||||
// code == 0, extract the inner data if present
|
||||
if innerData, hasInner := m["data"]; hasInner {
|
||||
@@ -102,20 +98,3 @@ 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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "create role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "delete role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "get role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "list roles failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -375,7 +375,9 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request")
|
||||
if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleListExecuteTransportError(t *testing.T) {
|
||||
@@ -403,7 +405,9 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-list", "--base-token", "app_x"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found")
|
||||
if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||
@@ -417,7 +421,9 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden")
|
||||
if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||
@@ -431,7 +437,9 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params")
|
||||
if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||
@@ -449,7 +457,9 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -477,7 +487,9 @@ func TestHandleRoleResponse(t *testing.T) {
|
||||
|
||||
t.Run("outer error code", func(t *testing.T) {
|
||||
rt := newRoleResponseRuntime(t)
|
||||
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error")
|
||||
if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
|
||||
@@ -562,7 +574,9 @@ 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"}}`
|
||||
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied")
|
||||
if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
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 baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "update role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "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 "", baseFlagErrorf("--%s cannot be empty", flagName)
|
||||
return "", common.FlagErrorf("--%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "@") {
|
||||
return raw, nil
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||
if path == "" {
|
||||
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||
}
|
||||
if pc.fio == nil {
|
||||
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName)
|
||||
return "", common.FlagErrorf("--%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 "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||
}
|
||||
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path)
|
||||
return "", common.FlagErrorf("--%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 baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
||||
return common.FlagErrorf("--%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 baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
||||
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 (%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))
|
||||
return common.FlagErrorf("--%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 "", baseFlagErrorf("specify one action")
|
||||
return "", common.FlagErrorf("specify one action")
|
||||
}
|
||||
if len(active) > 1 {
|
||||
flags := make([]string, 0, len(active))
|
||||
for _, item := range active {
|
||||
flags = append(flags, "--"+item)
|
||||
}
|
||||
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
||||
return "", common.FlagErrorf("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, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
||||
return nil, common.FlagErrorf("--%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, baseFlagErrorf("--%s must be a JSON object or array", flagName)
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,6 @@ 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",
|
||||
@@ -189,7 +188,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -243,30 +241,6 @@ 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
|
||||
@@ -280,39 +254,35 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Text equality filter",
|
||||
"Option intersection filter",
|
||||
"Query priority",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
|
||||
"keyword for record search",
|
||||
"field ID or name to search",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
"for keyword search only",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"Example: lark-cli base +record-search",
|
||||
"Example with filter/sort JSON",
|
||||
"Text equality filter",
|
||||
"Query priority",
|
||||
"Use --json only when you need to pass the full search body directly",
|
||||
"Happy path fields: keyword (string), search_fields",
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
"inventing search JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -637,7 +607,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -754,79 +724,6 @@ 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{})
|
||||
@@ -988,11 +885,11 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate == nil {
|
||||
t.Fatalf("record list validate should reject invalid query flags before dry-run")
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
@@ -1003,58 +900,6 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("record upsert map validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record list filter-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search flag validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
"sort-json": `[{"field":"Title","desc":false}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
|
||||
@@ -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 errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config")
|
||||
return fmt.Errorf("text 类型组件必须提供 data-config,包含必填字段 text")
|
||||
}
|
||||
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 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 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 nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -42,10 +41,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, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -153,7 +152,7 @@ func cloneValue(value interface{}) interface{} {
|
||||
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
||||
trimmed := strings.TrimSpace(typeName)
|
||||
if trimmed == "" {
|
||||
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
|
||||
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
|
||||
}
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "text", "phone", "url", "email", "barcode":
|
||||
@@ -193,7 +192,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{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
|
||||
return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,10 +252,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, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
|
||||
return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
|
||||
}
|
||||
if isHidden {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
|
||||
return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
|
||||
}
|
||||
spec, err := resolveFieldTypeSpec(typeName)
|
||||
if err != nil {
|
||||
@@ -355,7 +354,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
||||
if rawFields != "" {
|
||||
var fields []interface{}
|
||||
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
|
||||
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
|
||||
return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -367,7 +366,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, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
|
||||
return nil, fmt.Errorf("field %q: %w", spec.Name, err)
|
||||
}
|
||||
fields = append(fields, body)
|
||||
}
|
||||
@@ -411,15 +410,20 @@ 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, 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())
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -431,14 +435,18 @@ 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, 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 nil, fmt.Errorf("response parse error: %w", err)
|
||||
}
|
||||
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
|
||||
@@ -472,33 +480,6 @@ 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")
|
||||
@@ -544,7 +525,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, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
return nil, 0, fmt.Errorf("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 {
|
||||
@@ -574,7 +555,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, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
return nil, 0, fmt.Errorf("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 {
|
||||
@@ -596,7 +577,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, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
return nil, 0, fmt.Errorf("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 {
|
||||
@@ -622,7 +603,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
|
||||
return field, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
|
||||
return nil, fmt.Errorf("field %q not found", ref)
|
||||
}
|
||||
|
||||
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||
@@ -631,7 +612,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
|
||||
return table, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
|
||||
return nil, fmt.Errorf("table %q not found", ref)
|
||||
}
|
||||
|
||||
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||
@@ -640,7 +621,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
|
||||
return view, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
|
||||
return nil, fmt.Errorf("view %q not found", ref)
|
||||
}
|
||||
|
||||
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
|
||||
@@ -757,18 +738,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, baseValidationErrorf("--%s invalid JSON array", flagName)
|
||||
return nil, fmt.Errorf("--%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, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
|
||||
return nil, fmt.Errorf("--%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, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||
return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||
}
|
||||
result = append(result, namedTypeSpec{Name: name, Type: typeName})
|
||||
}
|
||||
@@ -1174,9 +1155,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
|
||||
return errs
|
||||
}
|
||||
|
||||
func formatDataConfigErrors(problems []string) error {
|
||||
if len(problems) == 0 {
|
||||
func formatDataConfigErrors(errs []string) error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
|
||||
return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user