mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
40 Commits
codex/opti
...
feat/AI_fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c | ||
|
|
f3949f04c4 | ||
|
|
62364fc320 | ||
|
|
2f4e2c3019 | ||
|
|
3990151122 | ||
|
|
fa929f02d6 |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
@@ -116,16 +116,14 @@ linters:
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
# These helpers emit legacy output.Err* / bare error shapes or drop
|
||||
# typed metadata such as Param/Cause. Migrated domains must use typed
|
||||
# common replacements or local typed helpers instead.
|
||||
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
[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: >-
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -2,6 +2,46 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
@@ -1026,6 +1066,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
|
||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
16
cmd/build.go
16
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -140,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -260,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -300,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -328,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
@@ -351,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -370,3 +381,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -129,3 +130,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Type: "api",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
Message: "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
183
cmd/skill/skill.go
Normal file
183
cmd/skill/skill.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skill implements the `lark-cli skills` command group, which serves
|
||||
// binary-embedded skill content to AI agents. The package is "skill"; the
|
||||
// user-facing verb is "skills".
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
|
||||
if f.SkillContent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"skill content not embedded in this build")
|
||||
}
|
||||
return skillcontent.New(f.SkillContent), nil
|
||||
}
|
||||
|
||||
type readEnvelope struct {
|
||||
Skill string `json:"skill"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Guidance string `json:"guidance,omitempty"`
|
||||
}
|
||||
|
||||
type listEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []skillcontent.SkillInfo `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type listPathEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []skillcontent.DirEntry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Read embedded skill content (list / read)",
|
||||
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
|
||||
"the CLI binary at build time, so it stays in sync with the CLI version. " +
|
||||
"Machine resources such as assets/ and scripts/ are not embedded.",
|
||||
}
|
||||
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.AddCommand(newListCmd(f), newReadCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [name[/path]]",
|
||||
Short: "List skills, or list one layer under a skill path (like ls)",
|
||||
Example: ` lark-cli skills list # all skills: name, description, version
|
||||
lark-cli skills list lark-doc # one layer under a skill (like ls)
|
||||
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"list takes at most 1 argument: [name[/path]]").
|
||||
WithHint("run 'lark-cli skills list --help'")
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
|
||||
return nil
|
||||
}
|
||||
entries, listed, err := r.ListPath(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
|
||||
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "read <name>[/<path>] [path]",
|
||||
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
|
||||
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
|
||||
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
|
||||
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
|
||||
lark-cli skills read lark-doc --json # JSON envelope`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name, relpath, err := parseReadTarget(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
var pathOut string
|
||||
if relpath == "" {
|
||||
content, err = r.ReadSkill(name)
|
||||
pathOut = "SKILL.md"
|
||||
} else {
|
||||
content, pathOut, err = r.ReadReference(name, relpath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isMain := pathOut == "SKILL.md"
|
||||
if asJSON {
|
||||
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
|
||||
if isMain {
|
||||
env.Guidance = readGuidance(name)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
|
||||
if _, err := f.IOStreams.Out.Write(content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
|
||||
}
|
||||
if isMain {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
|
||||
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
|
||||
func parseReadTarget(args []string) (name, relpath string, err error) {
|
||||
switch len(args) {
|
||||
case 1:
|
||||
name, relpath = skillcontent.SplitArg(args[0])
|
||||
return name, relpath, nil
|
||||
case 2:
|
||||
return args[0], args[1], nil
|
||||
default:
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
|
||||
WithHint("run 'lark-cli skills read --help'")
|
||||
}
|
||||
}
|
||||
|
||||
// readGuidance routes cross-skill "../lark-foo/..." references back through
|
||||
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
|
||||
// relative form must be rewritten.
|
||||
func readGuidance(name string) string {
|
||||
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
|
||||
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
|
||||
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
|
||||
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
|
||||
}
|
||||
306
cmd/skill/skill_test.go
Normal file
306
cmd/skill/skill_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// calFS is the default single-skill content tree for these tests. The embedded
|
||||
// FS is now injected through the Factory (no package global), so tests pass it
|
||||
// explicitly to run() — nothing is shared, so they are safe under -parallel.
|
||||
func calFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
}
|
||||
}
|
||||
|
||||
// run executes the skills command tree against the given content FS (may be nil
|
||||
// to exercise the not-embedded path) and returns stdout/stderr/err.
|
||||
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
// Isolate CLI config state so tests never read/write the real config dir
|
||||
// (repo convention).
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
|
||||
f.SkillContent = fsys
|
||||
cmd := NewCmdSkill(f)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err = cmd.Execute()
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func TestSkillList(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
// "ok" is an explicit success marker (the list envelope is a typed struct;
|
||||
// no automatic _notice attaches).
|
||||
if !got.OK {
|
||||
t.Error("expected ok=true in list envelope")
|
||||
}
|
||||
if got.Count != 1 || len(got.Skills) != 1 {
|
||||
t.Fatalf("count: got %d", got.Count)
|
||||
}
|
||||
if got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Errorf("name: got %v", got.Skills[0]["name"])
|
||||
}
|
||||
// Top-level list carries version + metadata, not a references list.
|
||||
if _, ok := got.Skills[0]["references"]; ok {
|
||||
t.Error("top-level list must not include references")
|
||||
}
|
||||
if got.Skills[0]["version"] != "1.0.0" {
|
||||
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
|
||||
}
|
||||
if _, ok := got.Skills[0]["metadata"]; !ok {
|
||||
t.Error("expected metadata in list entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListJSONFlagAccepted(t *testing.T) {
|
||||
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
|
||||
// so it stays symmetric with read --json.
|
||||
stdout, _, err := run(t, calFS(), "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("list --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Count != 1 {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPath(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("list <name> error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Path != "lark-calendar" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// One layer under the skill root: SKILL.md (file) + references (dir).
|
||||
if got.Count != 2 || len(got.Entries) != 2 {
|
||||
t.Fatalf("entries: got %+v", got.Entries)
|
||||
}
|
||||
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
|
||||
t.Errorf("entry[0]: got %+v", got.Entries[0])
|
||||
}
|
||||
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
|
||||
t.Errorf("entry[1]: got %+v", got.Entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "no-such-skill")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Fatalf("expected 'unknown skill' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Fatalf("expected 'invalid path' error, got %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListTooManyArgs(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "a", "b")
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
|
||||
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
|
||||
// omitted from the catalog (no blank entry).
|
||||
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
|
||||
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
|
||||
}
|
||||
stdout, _, err := run(t, fsys, "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadRaw(t *testing.T) {
|
||||
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
|
||||
t.Errorf("raw output: got %q", stdout)
|
||||
}
|
||||
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
|
||||
if strings.Contains(stdout, "Tip:") {
|
||||
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
|
||||
}
|
||||
// Guidance goes to stderr: own files via `skills read <name> ...`, and
|
||||
// cross-skill refs routed to `skills read <other-skill> ...` (version-
|
||||
// consistent), not "read directly".
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
|
||||
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
|
||||
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "instead of opening them directly") ||
|
||||
strings.Contains(stderr, "read those directly") {
|
||||
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v", e)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Guidance is a separate field, not merged into content.
|
||||
if got.Guidance == "" {
|
||||
t.Error("expected guidance field for main SKILL.md")
|
||||
}
|
||||
if strings.Contains(got.Content, "Tip:") {
|
||||
t.Error("guidance must not be merged into content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFile(t *testing.T) {
|
||||
// Both the 2-arg and slash forms read the same file, with no guidance tip.
|
||||
for _, args := range [][]string{
|
||||
{"read", "lark-calendar", "references/agenda.md"},
|
||||
{"read", "lark-calendar/references/agenda.md"},
|
||||
} {
|
||||
stdout, stderr, err := run(t, calFS(), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("read %v error: %v", args, err)
|
||||
}
|
||||
if stdout != "# Agenda" {
|
||||
t.Errorf("read %v output: got %q", args, stdout)
|
||||
}
|
||||
// Reference reads carry no guidance on either stream.
|
||||
if strings.Contains(stderr, "Tip:") {
|
||||
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFileJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read file --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Reference reads do not carry the guidance tip.
|
||||
if got.Guidance != "" {
|
||||
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read", "no-such")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadMissingArg(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
|
||||
t.Fatalf("expected arg error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillNilContentFS(t *testing.T) {
|
||||
_, _, err := run(t, nil, "list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SkillContent is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not embedded") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,21 @@ 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{}
|
||||
@@ -478,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
@@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -862,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallManual,
|
||||
@@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||
@@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
|
||||
@@ -5,9 +5,9 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||
// Problem.Code rather than the legacy envelope shape.
|
||||
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||
WithCode(vcNoteDetailNotFoundCode)
|
||||
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||
}
|
||||
if isLarkCode(typedNotFound, 99999) {
|
||||
t.Error("a different expected code must not match")
|
||||
}
|
||||
|
||||
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||
t.Error("typed error with another code must not match")
|
||||
}
|
||||
|
||||
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||
t.Error("untyped error must not match")
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ package vc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
23
events/whiteboard/native.go
Normal file
23
events/whiteboard/native.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||
type BoardWhiteboardUpdatedV1Data struct {
|
||||
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||
WhiteboardID string `json:"whiteboard_id"`
|
||||
// OperatorIDs lists the operators that produced this update batch.
|
||||
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||
}
|
||||
|
||||
// OperatorID identifies an operator that produced the whiteboard update,
|
||||
// expressed in the three Lark identity formats.
|
||||
type OperatorID struct {
|
||||
// OpenID is the operator's open_id within the current app.
|
||||
OpenID string `json:"open_id"`
|
||||
// UnionID is the operator's union_id across apps under the same ISV.
|
||||
UnionID string `json:"union_id"`
|
||||
// UserID is the operator's user_id within the tenant.
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
53
events/whiteboard/preconsume.go
Normal file
53
events/whiteboard/preconsume.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"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, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"param whiteboard_id is required for %s", eventType).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
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
|
||||
}
|
||||
}
|
||||
212
events/whiteboard/preconsume_test.go
Normal file
212
events/whiteboard/preconsume_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// 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/errs"
|
||||
"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)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("missing whiteboard_id should carry a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
// 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")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
// 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)
|
||||
48
events/whiteboard/register.go
Normal file
48
events/whiteboard/register.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package whiteboard registers Board-domain EventKeys.
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||
|
||||
// Keys returns all Board-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeWhiteboardUpdated,
|
||||
DisplayName: "Whiteboard updated",
|
||||
Description: "Pushed when the whiteboard content is updated.",
|
||||
EventType: eventTypeWhiteboardUpdated,
|
||||
Params: []event.ParamDef{
|
||||
{
|
||||
Name: "whiteboard_id",
|
||||
Type: event.ParamString,
|
||||
Required: true,
|
||||
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||
},
|
||||
},
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||
},
|
||||
},
|
||||
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +44,8 @@ type Factory struct {
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
|
||||
@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
base.Troubleshooter = ts
|
||||
}
|
||||
}
|
||||
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
||||
// returns these as free-text reason strings with no machine-readable field
|
||||
// name (verified for code 190014:
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
||||
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
||||
// prompt — rather than fabricated structured params. Lifted before the
|
||||
// category switch so any classified arm inherits it; the CategoryAPI arm
|
||||
// below prefers this server detail over the context-free APIHint default.
|
||||
detailHint := liftErrorDetailValues(resp)
|
||||
if detailHint != "" {
|
||||
base.Hint = detailHint
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
@@ -129,7 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
// A server-supplied detail (lifted into base.Hint above) wins over the
|
||||
// context-free APIHint default; only fall back to APIHint when absent.
|
||||
if base.Hint == "" {
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
}
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
@@ -214,6 +230,10 @@ func stringFromAny(v any) string {
|
||||
// per-subtype recovery hint before returning it, so the wire envelope
|
||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||
// Config categories have authoritative recovery guidance, so the curated
|
||||
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
||||
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
||||
// detail wins).
|
||||
p.Hint = ConfigHint(p.Subtype)
|
||||
return &errs.ConfigError{Problem: p}
|
||||
}
|
||||
@@ -258,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
||||
}
|
||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||
// Permission categories have authoritative recovery guidance (scopes to
|
||||
// grant, console URL), so the curated PermissionHint deliberately overrides
|
||||
// any server detail lifted into p.Hint (the opposite precedence from the
|
||||
// CategoryAPI arm, where the lifted detail wins).
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||
permErr := &errs.PermissionError{
|
||||
Problem: p,
|
||||
@@ -366,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
|
||||
return "check the calling identity has the required scope"
|
||||
}
|
||||
|
||||
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||
// strings and joins them with "; ". Returns "" when the structure is absent or
|
||||
// carries no non-empty value. The shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"<reason>"}]}}.
|
||||
func liftErrorDetailValues(resp map[string]any) string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
details, ok := errBlock["details"].([]any)
|
||||
if !ok || len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
var values []string
|
||||
for _, d := range details {
|
||||
m, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, _ := m["value"].(string); v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(values, "; ")
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
|
||||
@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
|
||||
// resp.error.details[].value into Problem.Hint when the response routes to the
|
||||
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
|
||||
// — only a human-readable reason string, no machine-readable field name. It is
|
||||
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
|
||||
// structured params.
|
||||
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
map[string]any{"value": "end_time should be later than start_time"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
|
||||
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
|
||||
// detail values are joined with "; " into a single Hint, and empty values are
|
||||
// skipped.
|
||||
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
map[string]any{"value": "first reason"},
|
||||
map[string]any{"value": ""},
|
||||
map[string]any{"value": "second reason"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Hint != "first reason; second reason" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
|
||||
// the details array (not a JSON object) are skipped rather than panicking, and
|
||||
// well-formed siblings still surface in the Hint.
|
||||
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
"i am a bare string, not an object",
|
||||
map[string]any{"value": "the real reason"},
|
||||
42,
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Hint != "the real reason" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
|
||||
// block, a non-array details field, and an empty details array all leave the
|
||||
// Hint untouched (no lifted detail) instead of erroring.
|
||||
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
}{
|
||||
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
|
||||
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
|
||||
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
|
||||
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
// With no liftable detail, the Hint must not echo a server detail.
|
||||
if strings.Contains(p.Hint, "nope") {
|
||||
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
||||
// when the upstream response omits it — wire envelope must omit the field.
|
||||
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
||||
|
||||
16
internal/errclass/codemeta_calendar.go
Normal file
16
internal/errclass/codemeta_calendar.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var calendarCodeMeta = map[int]CodeMeta{
|
||||
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }
|
||||
39
internal/errclass/codemeta_calendar_test.go
Normal file
39
internal/errclass/codemeta_calendar_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
|
||||
// via the codemeta_calendar.go init() merge to its expected
|
||||
// Category/Subtype/Retryable.
|
||||
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 190014: calendar "invalid params" with a field-level detail
|
||||
// (error.details[].value) lifted into Hint by BuildAPIError.
|
||||
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
18
internal/errclass/codemeta_minutes.go
Normal file
18
internal/errclass/codemeta_minutes.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is stable across minutes endpoints are registered;
|
||||
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
|
||||
// Command-specific messages, hints, and subtypes are layered on top via
|
||||
// per-command enrichment.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var minutesCodeMeta = map[int]CodeMeta{
|
||||
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
|
||||
@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
|
||||
if got, ok := LookupCodeMeta(2091001); ok {
|
||||
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(20050)
|
||||
if !ok {
|
||||
|
||||
19
internal/errclass/codemeta_vc.go
Normal file
19
internal/errclass/codemeta_vc.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes (e.g. 124002 "recording still generating", which has no
|
||||
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
|
||||
// per-command enrichment for a retry hint.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var vcCodeMeta = map[int]CodeMeta{
|
||||
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
|
||||
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(vcCodeMeta, "vc") }
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -80,7 +83,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +138,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +160,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -213,7 +227,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText() string {
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
99
internal/event/consume/startup_guard_test.go
Normal file
99
internal/event/consume/startup_guard_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/validate_params_test.go
Normal file
64
internal/event/consume/validate_params_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
@@ -231,14 +231,9 @@ func TestLoadAutoApproveSet(t *testing.T) {
|
||||
t.Fatal("expected non-empty auto-approve set")
|
||||
}
|
||||
|
||||
// From scope_overrides.json allow list
|
||||
if !aaSet["calendar:calendar.event:create"] {
|
||||
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
|
||||
}
|
||||
|
||||
// Verify allow list entries are present
|
||||
// From scope_priorities.json recommend=="true"
|
||||
if !aaSet["sheets:spreadsheet:read"] {
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
|
||||
}
|
||||
|
||||
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
||||
@@ -257,16 +252,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
if len(allowSet) == 0 {
|
||||
t.Fatal("expected non-empty override allow set")
|
||||
}
|
||||
|
||||
// Known entries from scope_overrides.json
|
||||
if !allowSet["calendar:calendar.event:create"] {
|
||||
t.Error("expected calendar:calendar.event:create in allow set")
|
||||
}
|
||||
if !allowSet["mail:event"] {
|
||||
t.Error("expected mail:event in allow set")
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,9 +266,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAutoApproveScope(t *testing.T) {
|
||||
// Known auto-approve scope (in allow list)
|
||||
if !IsAutoApproveScope("calendar:calendar.event:create") {
|
||||
t.Error("expected calendar:calendar.event:create to be auto-approve")
|
||||
// Known auto-approve scope (recommend=true in scope_priorities.json)
|
||||
if !IsAutoApproveScope("sheets:spreadsheet:read") {
|
||||
t.Error("expected sheets:spreadsheet:read to be auto-approve")
|
||||
}
|
||||
|
||||
// Completely unknown scope
|
||||
@@ -290,9 +279,8 @@ func TestIsAutoApproveScope(t *testing.T) {
|
||||
|
||||
func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
scopes := []string{
|
||||
"calendar:calendar.event:create", // auto-approve (in allow list)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
"sheets:spreadsheet:read", // auto-approve (in allow list)
|
||||
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
}
|
||||
|
||||
result := FilterAutoApproveScopes(scopes)
|
||||
@@ -300,10 +288,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
t.Fatal("expected at least 1 auto-approve scope in result")
|
||||
}
|
||||
|
||||
// Check that calendar:calendar.event:create is included
|
||||
// Check that sheets:spreadsheet:read is included
|
||||
found := false
|
||||
for _, s := range result {
|
||||
if s == "calendar:calendar.event:create" {
|
||||
if s == "sheets:spreadsheet:read" {
|
||||
found = true
|
||||
}
|
||||
// Ensure unknown scopes are not included
|
||||
@@ -312,7 +300,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected calendar:calendar.event:create in result")
|
||||
t.Error("expected sheets:spreadsheet:read in result")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,25 +12,7 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [
|
||||
"calendar:calendar.event:create",
|
||||
"calendar:calendar.event:delete",
|
||||
"calendar:calendar.event:read",
|
||||
"calendar:calendar.event:update",
|
||||
"calendar:calendar.free_busy:read",
|
||||
"calendar:calendar:create",
|
||||
"calendar:calendar:delete",
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar:update",
|
||||
"contact:user.basic_profile:readonly",
|
||||
"mail:event",
|
||||
"mail:user_mailbox.mail_contact:read",
|
||||
"mail:user_mailbox.mail_contact:write",
|
||||
"mail:user_mailbox.message.address:read",
|
||||
"mail:user_mailbox.message.body:read",
|
||||
"mail:user_mailbox.message.subject:read",
|
||||
"mail:user_mailbox.message:readonly"
|
||||
],
|
||||
"allow": [],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -37,9 +40,15 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
verifyTimeout = 10 * time.Second
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
skillsIndexMaxBodySize = 1 << 20
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
}
|
||||
|
||||
r := &NpmResult{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
|
||||
client := transport.NewHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
|
||||
return r
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
|
||||
if _, err := io.Copy(&r.Stdout, limited); err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
if r.Stdout.Len() > skillsIndexMaxBodySize {
|
||||
r.Stdout.Reset()
|
||||
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
|
||||
return r
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -232,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
|
||||
}
|
||||
if result.Stdout.Len() != 0 {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
oldTimeout := skillsIndexFetchTimeout
|
||||
officialSkillsIndexURL = server.URL
|
||||
skillsIndexFetchTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
officialSkillsIndexURL = oldURL
|
||||
skillsIndexFetchTimeout = oldTimeout
|
||||
})
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
var netErr net.Error
|
||||
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
|
||||
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
|
||||
return r
|
||||
}}).ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if !strings.Contains(result.Stdout.String(), "override-skill") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
|
||||
209
internal/skillcontent/reader.go
Normal file
209
internal/skillcontent/reader.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillcontent reads embedded skill content from an injected fs.FS
|
||||
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
|
||||
// fed straight back into `read`.
|
||||
type DirEntry struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
func (r *Reader) List() ([]SkillInfo, error) {
|
||||
entries, err := fs.ReadDir(r.fsys, ".")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
|
||||
}
|
||||
out := make([]SkillInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Skip dirs that aren't real skills (no SKILL.md).
|
||||
if info, ok := r.skillInfo(e.Name()); ok {
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
desc, version, metadata := parseFrontmatter(data)
|
||||
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
|
||||
}
|
||||
|
||||
// ListPath lists one directory layer (no recursion) under "<name>" or
|
||||
// "<name>/<sub>", returning the entries and the cleaned path listed.
|
||||
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
|
||||
name, sub := SplitArg(arg)
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir := name
|
||||
if sub != "" {
|
||||
cleaned, err := cleanSubPath(sub)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir = name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q not found in skill %q", sub, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
|
||||
}
|
||||
}
|
||||
entries, err := fs.ReadDir(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, dir, nil
|
||||
}
|
||||
|
||||
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
|
||||
// separator is a bare skill name (rest "").
|
||||
func SplitArg(arg string) (name, rest string) {
|
||||
name, rest, _ = strings.Cut(arg, "/")
|
||||
return name, rest
|
||||
}
|
||||
|
||||
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
|
||||
// unparseable frontmatter yields ("", "", nil), never an error.
|
||||
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
|
||||
lines := strings.Split(string(skillMD), "\n")
|
||||
if strings.TrimRight(lines[0], "\r") != "---" {
|
||||
return "", "", nil
|
||||
}
|
||||
block := make([]string, 0, len(lines))
|
||||
closed := false
|
||||
for _, ln := range lines[1:] {
|
||||
if strings.TrimRight(ln, "\r") == "---" {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
block = append(block, ln)
|
||||
}
|
||||
if !closed {
|
||||
return "", "", nil
|
||||
}
|
||||
var fm struct {
|
||||
Description string `yaml:"description"`
|
||||
Version string `yaml:"version"`
|
||||
Metadata map[string]any `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
return fm.Description, fm.Version, fm.Metadata
|
||||
}
|
||||
|
||||
func (r *Reader) ReadSkill(name string) ([]byte, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *Reader) ensureSkill(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
info, err := fs.Stat(r.fsys, name)
|
||||
if err != nil || !info.IsDir() {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknownSkill(name string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
|
||||
WithHint("run 'lark-cli skills list' to see available skills")
|
||||
}
|
||||
|
||||
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
|
||||
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
|
||||
func cleanSubPath(relpath string) (string, error) {
|
||||
cleaned := path.Clean(relpath)
|
||||
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
|
||||
// survives; reject it explicitly alongside "../".
|
||||
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
|
||||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid path %q: must be a relative path without '..'", relpath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
|
||||
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cleaned, err := cleanSubPath(relpath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
full := name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q not found in skill %q", relpath, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q is a directory, not a file", relpath)
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, cleaned, nil
|
||||
}
|
||||
290
internal/skillcontent/reader_test.go
Normal file
290
internal/skillcontent/reader_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
"lark-calendar/references/create.md": {Data: []byte("# Create")},
|
||||
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
|
||||
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
|
||||
"lark-im/references/send.md": {Data: []byte("# Send")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
r := New(testFS())
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("got %d skills, want 2", len(skills))
|
||||
}
|
||||
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
|
||||
t.Fatalf("skills not sorted by name: %v", skills)
|
||||
}
|
||||
if skills[0].Description != "Calendar skill" {
|
||||
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
|
||||
}
|
||||
// version is the frontmatter `version:` field, passed through for drift checks.
|
||||
if skills[0].Version != "1.0.0" {
|
||||
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
|
||||
}
|
||||
// metadata is the frontmatter `metadata:` block, passed through verbatim.
|
||||
if skills[0].Metadata == nil {
|
||||
t.Fatal("expected metadata for lark-calendar")
|
||||
}
|
||||
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
|
||||
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
|
||||
}
|
||||
// No frontmatter → empty description and nil metadata (omitted from JSON).
|
||||
if skills[1].Description != "" {
|
||||
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
|
||||
}
|
||||
if skills[1].Metadata != nil {
|
||||
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
|
||||
}
|
||||
if skills[1].Version != "" {
|
||||
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPath(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
// Skill root: direct children only (one layer), each path skill-prefixed.
|
||||
entries, listed, err := r.ListPath("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath root error: %v", err)
|
||||
}
|
||||
if listed != "lark-calendar" {
|
||||
t.Errorf("listed path: got %q", listed)
|
||||
}
|
||||
want := map[string]bool{ // path → isDir
|
||||
"lark-calendar/SKILL.md": false,
|
||||
"lark-calendar/references": true,
|
||||
"lark-calendar/assets": true,
|
||||
}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
|
||||
}
|
||||
for _, e := range entries {
|
||||
isDir, ok := want[e.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected entry %q", e.Path)
|
||||
continue
|
||||
}
|
||||
if e.IsDir != isDir {
|
||||
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
|
||||
}
|
||||
}
|
||||
// Entries are sorted by path.
|
||||
if entries[0].Path != "lark-calendar/SKILL.md" {
|
||||
t.Errorf("entries not sorted: %v", entries)
|
||||
}
|
||||
|
||||
// Subdirectory: one layer under <name>/<subpath>.
|
||||
subEntries, subListed, err := r.ListPath("lark-calendar/references")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath subdir error: %v", err)
|
||||
}
|
||||
if subListed != "lark-calendar/references" {
|
||||
t.Errorf("listed subpath: got %q", subListed)
|
||||
}
|
||||
if len(subEntries) != 2 ||
|
||||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
|
||||
subEntries[1].Path != "lark-calendar/references/create.md" {
|
||||
t.Errorf("subdir entries: got %v", subEntries)
|
||||
}
|
||||
|
||||
// Unknown skill → typed validation error.
|
||||
if _, _, err := r.ListPath("no-such-skill"); err == nil {
|
||||
t.Error("expected error for unknown skill")
|
||||
} else {
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Path that points at a file (not a dir) → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
|
||||
t.Error("expected error listing a file")
|
||||
} else if !strings.Contains(err.Error(), "is a file") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Nonexistent subpath → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Traversal in the subpath is rejected, no listing leaked.
|
||||
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
|
||||
entries, _, err := r.ListPath(bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if entries != nil {
|
||||
t.Errorf("entries leaked for %q: %v", bad, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkill(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, err := r.ReadSkill("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSkill error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
|
||||
t.Errorf("unexpected content: %q", string(data))
|
||||
}
|
||||
|
||||
_, err = r.ReadSkill("no-such-skill")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown skill")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
|
||||
t.Errorf("message: got %q", verr.Message)
|
||||
}
|
||||
|
||||
if _, err := r.ReadSkill("../etc"); err == nil {
|
||||
t.Error("expected error for name with separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadReference(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadReference error: %v", err)
|
||||
}
|
||||
if string(data) != "# Agenda" {
|
||||
t.Errorf("content: got %q", string(data))
|
||||
}
|
||||
if cleaned != "references/agenda.md" {
|
||||
t.Errorf("cleaned path: got %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
|
||||
t.Error("expected directory error")
|
||||
} else if !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
|
||||
data, _, err := r.ReadReference("lark-calendar", bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if data != nil {
|
||||
t.Errorf("content leaked for %q: %q", bad, string(data))
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected validation error for %q, got %T", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDesc string
|
||||
wantVer string
|
||||
wantHasMeta bool
|
||||
}{
|
||||
{
|
||||
name: "description, version and metadata",
|
||||
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
|
||||
wantDesc: "My skill",
|
||||
wantVer: "2.1.0",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "description only, no metadata",
|
||||
input: "---\ndescription: Plain\n---\nbody\n",
|
||||
wantDesc: "Plain",
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
input: "no frontmatter here\n",
|
||||
},
|
||||
{
|
||||
name: "unclosed frontmatter",
|
||||
input: "---\ndescription: Never closed\n",
|
||||
},
|
||||
{
|
||||
name: "malformed YAML inside frontmatter",
|
||||
input: "---\n: bad: yaml: [\n---\nbody\n",
|
||||
},
|
||||
{
|
||||
name: "CRLF line endings",
|
||||
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
|
||||
wantDesc: "CRLF skill",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, ver, meta := parseFrontmatter([]byte(tc.input))
|
||||
if desc != tc.wantDesc {
|
||||
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
if ver != tc.wantVer {
|
||||
t.Errorf("version = %q, want %q", ver, tc.wantVer)
|
||||
}
|
||||
if (meta != nil) != tc.wantHasMeta {
|
||||
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkillMissingFile(t *testing.T) {
|
||||
// Use a separate MapFS so testFS() (and TestList) are unaffected.
|
||||
emptyFS := fstest.MapFS{
|
||||
"lark-empty/references/x.md": {Data: []byte("# X")},
|
||||
}
|
||||
r := New(emptyFS)
|
||||
_, err := r.ReadSkill("lark-empty")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SKILL.md is absent")
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(err, &ierr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,30 @@ func ParseGlobalSkillsJSON(text string) []string {
|
||||
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{}
|
||||
@@ -160,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
|
||||
if len(parts) > 0 {
|
||||
candidate := parts[0]
|
||||
// Check if it's a valid official skill name
|
||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
@@ -223,6 +246,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
@@ -258,14 +282,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
@@ -327,6 +346,40 @@ 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 {
|
||||
|
||||
@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
input := `Available Skills
|
||||
│ lark-calendar
|
||||
│ official-shared
|
||||
│ bad/name
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "official-shared"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
@@ -110,6 +123,43 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialIndexOut string
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialIndexErr error
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
@@ -166,6 +218,8 @@ type fakeSkillsRunner struct {
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
}
|
||||
@@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func officialSkillsIndexOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"skills":[`)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Global Skills\n\n")
|
||||
@@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string {
|
||||
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
|
||||
@@ -255,9 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
@@ -289,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
if runner.listedIndex != 1 {
|
||||
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||
}
|
||||
if runner.listedOfficial != 0 {
|
||||
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -322,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -342,9 +527,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -367,9 +553,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
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"),
|
||||
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})
|
||||
@@ -391,9 +578,10 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
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})
|
||||
@@ -420,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -445,11 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -477,11 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -510,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -527,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -551,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -576,11 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -601,11 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -625,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -643,9 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
|
||||
@@ -15,8 +15,21 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -16,8 +16,22 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
||||
// Migrated code must call the domain's typed API wrapper or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
//
|
||||
// Files that do not import shortcuts/common are skipped: the legacy helpers
|
||||
// are methods on common.RuntimeContext, so a same-named method on another
|
||||
// receiver (for example the event domain's APIClient interface, whose
|
||||
// implementation classifies into typed errs.* errors) is not a legacy call.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !importsPath(file, commonImportPath) {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
@@ -53,7 +61,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importsPath reports whether the file imports the given package path.
|
||||
func importsPath(file *ast.File, importPath string) bool {
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(imp.Path.Value, "`\"") == importPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -618,6 +618,35 @@ func boom() error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
@@ -662,7 +691,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -784,6 +813,8 @@ func boom() error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -801,9 +832,33 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
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
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -851,14 +906,14 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
src := `package contact
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -895,8 +950,12 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -924,8 +983,46 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
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_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
src := `package contact
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -933,7 +1030,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1003,3 +1100,23 @@ func boom() error {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
||||
// The event domain's APIClient interface has a same-named CallAPI method
|
||||
// whose implementation classifies into typed errs.* errors; without the
|
||||
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
||||
// helper and must not fire.
|
||||
src := `package vc
|
||||
|
||||
import "github.com/larksuite/cli/internal/event"
|
||||
|
||||
func boom(rt event.APIClient) error {
|
||||
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.49",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -196,9 +196,7 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+advperm-enable", "--base-token", "app_x"}
|
||||
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
|
||||
}
|
||||
|
||||
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
|
||||
@@ -226,7 +224,5 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
|
||||
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
|
||||
}
|
||||
|
||||
@@ -55,24 +55,24 @@ func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *c
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
return baseFlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return common.FlagErrorf("--type must not be blank")
|
||||
return baseFlagErrorf("--type must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
return baseFlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
return baseFlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&dsl); err != nil {
|
||||
return common.FlagErrorf("--dsl invalid JSON: %v", err)
|
||||
return baseFlagErrorf("--dsl invalid JSON: %v", err)
|
||||
}
|
||||
_, hasDim := dsl["dimensions"]
|
||||
_, hasMeas := dsl["measures"]
|
||||
if !hasDim && !hasMeas {
|
||||
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -24,76 +28,198 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
|
||||
// structured ErrAPI, with server-provided message/hint promoted to the top level.
|
||||
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
return nil, baseAPIBoundaryError(err, action)
|
||||
}
|
||||
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
|
||||
}
|
||||
if _, exists := resultMap["code"]; !exists {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
|
||||
}
|
||||
code, numeric := util.ToFloat64(resultMap["code"])
|
||||
if !numeric {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
|
||||
}
|
||||
if code == 0 {
|
||||
return resultMap["data"], nil
|
||||
}
|
||||
|
||||
larkCode := int(code)
|
||||
msg := extractDataErrorMessage(resultMap)
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
msg, _ = resultMap["msg"].(string)
|
||||
}
|
||||
|
||||
detail := extractErrorDetail(resultMap)
|
||||
apiErr := output.ErrAPI(larkCode, msg, detail)
|
||||
hint := extractErrorHint(resultMap)
|
||||
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
|
||||
apiErr.Detail.Hint = hint
|
||||
}
|
||||
if apiErr.Detail != nil {
|
||||
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
|
||||
}
|
||||
return nil, apiErr
|
||||
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
|
||||
detailMap, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
|
||||
// typed envelope and exists so call sites read as flag rejections.
|
||||
func baseFlagErrorf(format string, args ...any) error {
|
||||
return baseValidationErrorf(format, args...)
|
||||
}
|
||||
|
||||
func baseValidationErrorf(format string, args ...any) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||
if params := flagParams(msg); len(params) > 0 {
|
||||
err = err.WithParam(params[0].Name).WithParams(params...)
|
||||
}
|
||||
for key, value := range detailMap {
|
||||
if value == nil {
|
||||
delete(detailMap, key)
|
||||
if cause := firstErrorArg(args); cause != nil {
|
||||
err = err.WithCause(cause)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func flagParams(msg string) []errs.InvalidParam {
|
||||
reason := msg
|
||||
seen := map[string]bool{}
|
||||
params := []errs.InvalidParam{}
|
||||
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
|
||||
end := start + 2
|
||||
for end < len(msg) {
|
||||
ch := msg[end]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if end > start+2 {
|
||||
name := msg[start:end]
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
|
||||
}
|
||||
}
|
||||
msg = msg[end:]
|
||||
}
|
||||
if len(detailMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
return detailMap
|
||||
return params
|
||||
}
|
||||
|
||||
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
|
||||
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
|
||||
return detail
|
||||
}
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if detail, ok := nonNilMapValue(data, "error"); ok {
|
||||
return detail
|
||||
func firstErrorArg(args []any) error {
|
||||
for _, arg := range args {
|
||||
if err, ok := arg.(error); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
|
||||
if src == nil {
|
||||
return nil, false
|
||||
// baseMissingFileIOError reports a broken runtime wiring: a command that needs
|
||||
// local file access was constructed without a FileIO provider. The user cannot
|
||||
// fix this by changing flags, so it classifies as internal, not validation.
|
||||
func baseMissingFileIOError(format string, args ...any) error {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
|
||||
}
|
||||
|
||||
func baseInputStatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := src[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
|
||||
}
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return nil, false
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func baseSaveError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
|
||||
case errors.As(err, &me):
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
|
||||
default:
|
||||
return value, true
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
func baseAPIBoundaryError(err error, action string) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
|
||||
}
|
||||
|
||||
func baseUploadAttachmentError(filePath string, err error) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
|
||||
}
|
||||
|
||||
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
|
||||
if resultMap == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
|
||||
}
|
||||
if msg := extractDataErrorMessage(resultMap); msg != "" {
|
||||
resultMap["msg"] = msg
|
||||
}
|
||||
hint := extractErrorHint(resultMap)
|
||||
if logID := extractBaseErrorLogID(resultMap); logID != "" {
|
||||
resultMap["log_id"] = logID
|
||||
}
|
||||
err := errclass.BuildAPIError(resultMap, cc)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok && hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
return err
|
||||
}
|
||||
result, parseErr := decodeBaseV3Response(body)
|
||||
if parseErr != nil {
|
||||
return err
|
||||
}
|
||||
enriched := baseAPIErrorFromResult(result, cc)
|
||||
if enriched == nil {
|
||||
return err
|
||||
}
|
||||
src, _ := errs.ProblemOf(enriched)
|
||||
dst, _ := errs.ProblemOf(err)
|
||||
if src != nil && dst != nil {
|
||||
dst.Message = src.Message
|
||||
dst.Hint = src.Hint
|
||||
// A body without log_id must not erase a header-derived LogID
|
||||
// already carried by err.
|
||||
if src.LogID != "" {
|
||||
dst.LogID = src.LogID
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
|
||||
for _, key := range []string{"log_id", "logid"} {
|
||||
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"log_id", "logid"} {
|
||||
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if detail, ok := data["error"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"log_id", "logid"} {
|
||||
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractErrorHint(resultMap map[string]interface{}) string {
|
||||
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||
if hint := consumeStringField(detail, "hint"); hint != "" {
|
||||
|
||||
@@ -4,30 +4,15 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
)
|
||||
|
||||
func TestErrorDetailHelpers(t *testing.T) {
|
||||
if value, ok := nonNilMapValue(nil, "error"); ok || value != nil {
|
||||
t.Fatalf("nil map should not return value")
|
||||
}
|
||||
if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil {
|
||||
t.Fatalf("nil entry should not return value")
|
||||
}
|
||||
detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
|
||||
if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil {
|
||||
t.Fatalf("expected non-nil detail")
|
||||
}
|
||||
if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil {
|
||||
t.Fatalf("expected root detail")
|
||||
}
|
||||
if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil {
|
||||
t.Fatalf("expected nested detail")
|
||||
}
|
||||
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
|
||||
t.Fatalf("hint=%q", got)
|
||||
}
|
||||
@@ -53,9 +38,12 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
||||
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||
t.Fatalf("err=%v", err)
|
||||
} else {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
|
||||
t.Fatalf("expected structured code 190001, got %v", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != 190001 {
|
||||
t.Fatalf("expected typed code 190001, got %T %v", err, err)
|
||||
}
|
||||
if p.Hint != "check field name" {
|
||||
t.Fatalf("hint=%q", p.Hint)
|
||||
}
|
||||
}
|
||||
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
|
||||
@@ -63,7 +51,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
||||
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 800010407,
|
||||
"msg": "cell value invalid",
|
||||
@@ -87,55 +75,27 @@ func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
|
||||
errDetail := exitErr.Detail
|
||||
if errDetail.Code != 800010407 {
|
||||
t.Fatalf("code=%d", errDetail.Code)
|
||||
if p.Code != 800010407 {
|
||||
t.Fatalf("code=%d", p.Code)
|
||||
}
|
||||
if errDetail.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", errDetail.Hint)
|
||||
if p.Message != "The cell value does not match the expected input shape." {
|
||||
t.Fatalf("message=%q", p.Message)
|
||||
}
|
||||
detail, _ := errDetail.Detail.(map[string]interface{})
|
||||
if detail == nil {
|
||||
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
|
||||
if p.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", p.Hint)
|
||||
}
|
||||
if _, exists := detail["message"]; exists {
|
||||
t.Fatalf("detail should not repeat message: %#v", detail)
|
||||
}
|
||||
if _, exists := detail["hint"]; exists {
|
||||
t.Fatalf("detail should not repeat hint: %#v", detail)
|
||||
}
|
||||
if _, exists := detail["docs_url"]; exists {
|
||||
t.Fatalf("detail should omit nil docs_url: %#v", detail)
|
||||
}
|
||||
if detail["level"] != "error" {
|
||||
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
|
||||
}
|
||||
if detail["extra_context"] != "future detail field" {
|
||||
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
|
||||
}
|
||||
if detail["path"] != "Amount" || detail["value"] != "abc" {
|
||||
t.Fatalf("cleaned detail mismatch: %#v", detail)
|
||||
}
|
||||
if detail["logid"] != "20260508160000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
}
|
||||
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
|
||||
t.Fatalf("retryable=%v", detail["retryable"])
|
||||
}
|
||||
table, _ := detail["table"].(map[string]interface{})
|
||||
if table["id"] != "tbl_1" || table["name"] != "Orders" {
|
||||
t.Fatalf("table=%#v", detail["table"])
|
||||
if p.LogID != "20260508160000000000000000000000" {
|
||||
t.Fatalf("logID=%q", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
|
||||
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": output.LarkErrTokenNoPermission,
|
||||
"code": 99991676,
|
||||
"msg": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
@@ -146,15 +106,15 @@ func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T)
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Message != "Permission denied [99991676]" {
|
||||
t.Fatalf("message=%q", exitErr.Detail.Message)
|
||||
if p.Code != 99991676 {
|
||||
t.Fatalf("code=%d", p.Code)
|
||||
}
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
|
||||
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,16 +127,91 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
|
||||
attachBaseErrorLogID(result, "20260508170000000000000000000000")
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["logid"] != "20260508170000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
if p.LogID != "20260508170000000000000000000000" {
|
||||
t.Fatalf("logID=%q", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultRejectsNonNumericCode(t *testing.T) {
|
||||
for _, code := range []interface{}{"oops", map[string]interface{}{}, nil} {
|
||||
result := map[string]interface{}{"code": code, "msg": "weird envelope"}
|
||||
_, err := handleBaseAPIResultAny(result, nil, "list tables")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("code=%#v: expected typed error, got %T %v", code, err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("code=%#v: category/subtype=%s/%s", code, p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "list tables") {
|
||||
t.Fatalf("code=%#v: message=%q", code, p.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichBaseAPIErrorFromBodyLogIDMerge(t *testing.T) {
|
||||
t.Run("body without log_id keeps header-derived LogID", func(t *testing.T) {
|
||||
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
|
||||
err := enrichBaseAPIErrorFromBody(outer, []byte(`{"code":190001,"msg":"boom"}`), errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if p.Message != "boom" {
|
||||
t.Fatalf("message=%q", p.Message)
|
||||
}
|
||||
if p.LogID != "header-log-id" {
|
||||
t.Fatalf("logID=%q, want header-log-id", p.LogID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("body log_id overrides header-derived LogID", func(t *testing.T) {
|
||||
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
|
||||
body := `{"code":190001,"msg":"boom","data":{"error":{"logid":"body-log-id"}}}`
|
||||
err := enrichBaseAPIErrorFromBody(outer, []byte(body), errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if p.LogID != "body-log-id" {
|
||||
t.Fatalf("logID=%q, want body-log-id", p.LogID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseMissingFileIOErrorIsInternal(t *testing.T) {
|
||||
p, ok := errs.ProblemOf(baseMissingFileIOError("file operations require a FileIO provider"))
|
||||
if !ok {
|
||||
t.Fatal("expected typed error")
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr struct{}
|
||||
|
||||
func (assertErr) Error() string { return "network timeout" }
|
||||
|
||||
func assertProblemCode(t *testing.T, err error, code int, messageParts ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error with code %d", code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Code != code {
|
||||
t.Fatalf("code=%d, want %d; err=%v", p.Code, code, err)
|
||||
}
|
||||
for _, part := range messageParts {
|
||||
if !strings.Contains(p.Message, part) {
|
||||
t.Fatalf("message=%q missing %q", p.Message, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -513,6 +514,65 @@ func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
params []string
|
||||
}{
|
||||
{
|
||||
name: "create blank name",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
|
||||
params: []string{"--name"},
|
||||
},
|
||||
{
|
||||
name: "move conflicting sibling anchors",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
|
||||
params: []string{"--before-id", "--after-id"},
|
||||
},
|
||||
{
|
||||
name: "rename blank name",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
|
||||
params: []string{"--name"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.params[0] {
|
||||
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
|
||||
}
|
||||
if len(validationErr.Params) != len(tt.params) {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
||||
}
|
||||
for i, param := range tt.params {
|
||||
if validationErr.Params[i].Name != param {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
||||
}
|
||||
if validationErr.Params[i].Reason == "" {
|
||||
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -871,10 +931,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
t.Run("list-http-404", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables",
|
||||
Status: 404,
|
||||
Body: "404 page not found",
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables",
|
||||
Status: 404,
|
||||
RawBody: []byte("404 page not found"),
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
@@ -2093,6 +2153,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
|
||||
t.Fatalf("err=%v should name the offending file", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||
@@ -2262,6 +2325,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "../escape",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -2458,21 +2538,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
if err == nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
var partialErr *output.PartialFailureError
|
||||
if !errors.As(err, &partialErr) {
|
||||
t.Fatalf("expected partial failure error, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
||||
failed, _ := detail["failed"].([]map[string]interface{})
|
||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if detail["log_id"] != "202605270001" {
|
||||
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
|
||||
if envelope["ok"] != false {
|
||||
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("message=%q", msg)
|
||||
}
|
||||
downloaded, _ := data["downloaded"].([]interface{})
|
||||
failed, _ := data["failed"].([]interface{})
|
||||
if len(downloaded) != 1 || len(failed) != 1 {
|
||||
t.Fatalf("data=%#v", data)
|
||||
}
|
||||
downloadedItem, _ := downloaded[0].(map[string]interface{})
|
||||
failedItem, _ := failed[0].(map[string]interface{})
|
||||
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
|
||||
t.Fatalf("data=%#v", data)
|
||||
}
|
||||
if data["log_id"] != "202605270001" {
|
||||
t.Fatalf("data=%#v, want log_id", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected first file to remain: %v", err)
|
||||
|
||||
@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -43,7 +42,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
||||
|
||||
var questionIds []string
|
||||
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err)
|
||||
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err)
|
||||
}
|
||||
|
||||
_, err := baseV3Call(runtime, "DELETE",
|
||||
|
||||
@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "PATCH",
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -62,31 +61,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
|
||||
attachments, hasAttachments := raw["attachments"]
|
||||
|
||||
if !hasAttachments && fields == nil {
|
||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
}
|
||||
|
||||
if hasAttachments {
|
||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||
if runtime.Str("base-token") == "" {
|
||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
}
|
||||
|
||||
attMap, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
}
|
||||
for fieldName, value := range attMap {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
return baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
for i, item := range paths {
|
||||
if _, ok := item.(string); !ok {
|
||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,21 +110,21 @@ func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if attachments, ok := raw["attachments"]; ok {
|
||||
attObj, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
return nil, nil, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
}
|
||||
if len(attObj) > 0 {
|
||||
attMap = make(map[string][]string, len(attObj))
|
||||
for fieldName, value := range attObj {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
return nil, nil, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
filePaths := make([]string, 0, len(paths))
|
||||
for _, item := range paths {
|
||||
if s, ok := item.(string); ok {
|
||||
filePaths = append(filePaths, s)
|
||||
} else {
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
return nil, nil, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
}
|
||||
}
|
||||
if len(filePaths) > 0 {
|
||||
@@ -195,33 +194,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
}
|
||||
|
||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||
allPaths := collectUniquePaths(attachmentMap)
|
||||
if len(allPaths) == 0 {
|
||||
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
||||
return baseFlagErrorf("attachments in --json contains no valid file paths")
|
||||
}
|
||||
|
||||
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
||||
sizeMap := make(map[string]int64, len(allPaths))
|
||||
for _, filePath := range allPaths {
|
||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
||||
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
||||
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
||||
return baseValidationErrorf("attachment file %s is not a regular file", filePath)
|
||||
}
|
||||
sizeMap[filePath] = fileInfo.Size()
|
||||
}
|
||||
@@ -328,7 +327,7 @@ func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, t
|
||||
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
||||
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
||||
return nil, baseUploadAttachmentError(filePath, err)
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,14 @@ import (
|
||||
// - Inner: business-level code/message inside the data object
|
||||
//
|
||||
// The data field may be a JSON object (actual behavior) or a JSON string (per doc).
|
||||
func handleRoleAPIResponse(runtime *common.RuntimeContext, apiResp *larkcore.ApiResp, action string) error {
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
enriched := enrichBaseAPIErrorFromBody(err, apiResp.RawBody, runtime.APIClassifyContext())
|
||||
return prefixRoleActionError(enriched, action)
|
||||
}
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, action)
|
||||
}
|
||||
|
||||
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
@@ -24,23 +33,17 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &resp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
msg := resp.Msg
|
||||
// When outer msg is empty, try to extract error details from data.error.message
|
||||
if msg == "" && len(resp.Data) > 0 {
|
||||
var errData struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" {
|
||||
msg = errData.Error.Message
|
||||
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg}
|
||||
if len(resp.Data) > 0 {
|
||||
var data interface{}
|
||||
if json.Unmarshal(resp.Data, &data) == nil {
|
||||
result["data"] = data
|
||||
}
|
||||
}
|
||||
return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
|
||||
return baseRoleAPIError(runtime, result, action)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
|
||||
@@ -75,7 +78,8 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
}
|
||||
if codeInt != 0 {
|
||||
msg, _ := m["message"].(string)
|
||||
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
|
||||
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
|
||||
return baseRoleAPIError(runtime, result, action)
|
||||
}
|
||||
// code == 0, extract the inner data if present
|
||||
if innerData, hasInner := m["data"]; hasInner {
|
||||
@@ -98,3 +102,20 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseRoleAPIError(runtime *common.RuntimeContext, result map[string]interface{}, action string) error {
|
||||
return prefixRoleActionError(baseAPIErrorFromResult(result, runtime.APIClassifyContext()), action)
|
||||
}
|
||||
|
||||
// prefixRoleActionError prepends the failed role action ("create role failed",
|
||||
// "get role failed", ...) to a typed error's message so both the classified
|
||||
// outer-response path and the parsed-body path carry the same context.
|
||||
func prefixRoleActionError(err error, action string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok && action != "" {
|
||||
p.Message = action + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ var BaseRoleCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
||||
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "create role failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "create role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ var BaseRoleDelete = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "delete role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ var BaseRoleGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "get role failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "get role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ var BaseRoleList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "list roles failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -375,9 +375,7 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
|
||||
if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request")
|
||||
}
|
||||
|
||||
func TestBaseRoleListExecuteTransportError(t *testing.T) {
|
||||
@@ -405,9 +403,7 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-list", "--base-token", "app_x"}
|
||||
if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found")
|
||||
}
|
||||
|
||||
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||
@@ -421,9 +417,7 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
|
||||
if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden")
|
||||
}
|
||||
|
||||
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||
@@ -437,9 +431,7 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
|
||||
if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params")
|
||||
}
|
||||
|
||||
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||
@@ -457,9 +449,7 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
|
||||
if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -487,9 +477,7 @@ func TestHandleRoleResponse(t *testing.T) {
|
||||
|
||||
t.Run("outer error code", func(t *testing.T) {
|
||||
rt := newRoleResponseRuntime(t)
|
||||
if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error")
|
||||
})
|
||||
|
||||
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
|
||||
@@ -574,9 +562,7 @@ func TestHandleRoleResponse(t *testing.T) {
|
||||
t.Run("business code non-zero", func(t *testing.T) {
|
||||
rt := newRoleResponseRuntime(t)
|
||||
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
|
||||
if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied")
|
||||
})
|
||||
|
||||
t.Run("data is array", func(t *testing.T) {
|
||||
|
||||
@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
||||
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "update role failed")
|
||||
return handleRoleAPIResponse(runtime, apiResp, "update role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,34 +30,34 @@ func baseTableID(runtime *common.RuntimeContext) string {
|
||||
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", common.FlagErrorf("--%s cannot be empty", flagName)
|
||||
return "", baseFlagErrorf("--%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "@") {
|
||||
return raw, nil
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||
if path == "" {
|
||||
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||
}
|
||||
if pc.fio == nil {
|
||||
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
|
||||
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName)
|
||||
}
|
||||
f, err := pc.fio.Open(path)
|
||||
if err != nil {
|
||||
var pathErr *fileio.PathValidationError
|
||||
if errors.As(err, &pathErr) {
|
||||
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||
return "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||
}
|
||||
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path)
|
||||
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
if syntaxErr, ok := err.(*json.SyntaxError); ok {
|
||||
return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
||||
return baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
||||
}
|
||||
if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
|
||||
if typeErr.Field != "" {
|
||||
return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
||||
return baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
||||
}
|
||||
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
}
|
||||
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
}
|
||||
|
||||
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
|
||||
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return "", common.FlagErrorf("specify one action")
|
||||
return "", baseFlagErrorf("specify one action")
|
||||
}
|
||||
if len(active) > 1 {
|
||||
flags := make([]string, 0, len(active))
|
||||
for _, item := range active {
|
||||
flags = append(flags, "--"+item)
|
||||
}
|
||||
return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
||||
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
||||
}
|
||||
return active[0], nil
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
|
||||
for idx, item := range arr {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
||||
return nil, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
||||
}
|
||||
items = append(items, obj)
|
||||
}
|
||||
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
|
||||
case map[string]interface{}, []interface{}:
|
||||
return value, nil
|
||||
default:
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object or array", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package base
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
// text 类型必须提供 data-config(含 text 内容)
|
||||
if strings.ToLower(runtime.Str("type")) == "text" {
|
||||
return fmt.Errorf("text 类型组件必须提供 data-config,包含必填字段 text")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
|
||||
if fieldType == "lookup" {
|
||||
guidePath = "skills/lark-base/references/lookup-field-guide.md"
|
||||
}
|
||||
return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
||||
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -41,10 +42,10 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -152,7 +153,7 @@ func cloneValue(value interface{}) interface{} {
|
||||
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
||||
trimmed := strings.TrimSpace(typeName)
|
||||
if trimmed == "" {
|
||||
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
|
||||
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
|
||||
}
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "text", "phone", "url", "email", "barcode":
|
||||
@@ -192,7 +193,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
||||
case "modifiedtime", "modified_time", "modified-time":
|
||||
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
||||
default:
|
||||
return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
|
||||
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,10 +253,10 @@ func normalizeSelectOptions(raw interface{}) []interface{} {
|
||||
|
||||
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
|
||||
if isPrimary {
|
||||
return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
|
||||
}
|
||||
if isHidden {
|
||||
return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
|
||||
}
|
||||
spec, err := resolveFieldTypeSpec(typeName)
|
||||
if err != nil {
|
||||
@@ -354,7 +355,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
||||
if rawFields != "" {
|
||||
var fields []interface{}
|
||||
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
|
||||
return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
|
||||
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -366,7 +367,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
||||
for _, spec := range specs {
|
||||
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", spec.Name, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
|
||||
}
|
||||
fields = append(fields, body)
|
||||
}
|
||||
@@ -410,20 +411,15 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
||||
h.Set("X-App-Id", runtime.Config.AppID)
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, baseAPIBoundaryError(err, "API call failed")
|
||||
}
|
||||
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
|
||||
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
|
||||
return nil, statusErr
|
||||
}
|
||||
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
|
||||
}
|
||||
result, parseErr := decodeBaseV3Response(resp.RawBody)
|
||||
if parseErr == nil && baseV3ResultCode(result) != 0 {
|
||||
attachBaseErrorLogID(result, baseResponseLogID(resp))
|
||||
return result, nil
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
body := strings.TrimSpace(string(resp.RawBody))
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
@@ -435,18 +431,14 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("response parse error: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func baseV3ResultCode(result map[string]interface{}) int {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt(result["code"])
|
||||
}
|
||||
|
||||
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
|
||||
if result == nil || strings.TrimSpace(logID) == "" {
|
||||
return
|
||||
@@ -480,6 +472,33 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
|
||||
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
|
||||
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
|
||||
if resp == nil || resp.StatusCode < http.StatusBadRequest {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(classified)
|
||||
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
return nil
|
||||
}
|
||||
body := strings.TrimSpace(string(resp.RawBody))
|
||||
if resp.StatusCode >= http.StatusInternalServerError {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
|
||||
if logID := baseResponseLogID(resp); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
|
||||
if logID := baseResponseLogID(resp); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
result, err := baseV3Raw(runtime, method, path, params, data)
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
@@ -525,7 +544,7 @@ func toStringSlice(v interface{}) []string {
|
||||
|
||||
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||
if limit <= 0 {
|
||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
||||
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||
if err != nil {
|
||||
@@ -555,7 +574,7 @@ func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, lim
|
||||
|
||||
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||
if limit <= 0 {
|
||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
||||
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||
if err != nil {
|
||||
@@ -577,7 +596,7 @@ func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, of
|
||||
|
||||
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||
if limit <= 0 {
|
||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
||||
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||
if err != nil {
|
||||
@@ -603,7 +622,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
|
||||
return field, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("field %q not found", ref)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
|
||||
}
|
||||
|
||||
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||
@@ -612,7 +631,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
|
||||
return table, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("table %q not found", ref)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
|
||||
}
|
||||
|
||||
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||
@@ -621,7 +640,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
|
||||
return view, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("view %q not found", ref)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
|
||||
}
|
||||
|
||||
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
|
||||
@@ -738,18 +757,18 @@ func canonicalValue(v interface{}) string {
|
||||
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
|
||||
var tuples []interface{}
|
||||
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
|
||||
return nil, fmt.Errorf("--%s invalid JSON array", flagName)
|
||||
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
|
||||
}
|
||||
result := make([]namedTypeSpec, 0, len(tuples))
|
||||
for idx, item := range tuples {
|
||||
pair, ok := item.([]interface{})
|
||||
if !ok || len(pair) != 2 {
|
||||
return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1)
|
||||
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
|
||||
}
|
||||
name, ok1 := pair[0].(string)
|
||||
typeName, ok2 := pair[1].(string)
|
||||
if !ok1 || !ok2 {
|
||||
return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||
}
|
||||
result = append(result, namedTypeSpec{Name: name, Type: typeName})
|
||||
}
|
||||
@@ -1155,9 +1174,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
|
||||
return errs
|
||||
}
|
||||
|
||||
func formatDataConfigErrors(errs []string) error {
|
||||
if len(errs) == 0 {
|
||||
func formatDataConfigErrors(problems []string) error {
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -19,7 +20,7 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
||||
case "", "json", "markdown":
|
||||
return nil
|
||||
default:
|
||||
return output.ErrValidation("--format must be json or markdown")
|
||||
return baseValidationErrorf("--format must be json or markdown")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
||||
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
|
||||
}
|
||||
rendered, err := renderer(data)
|
||||
if err != nil {
|
||||
@@ -43,7 +44,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
}
|
||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
return baseContentSafetyBlockError(scanResult)
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||
@@ -52,6 +53,20 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
|
||||
message := "content safety violation detected"
|
||||
var rules []string
|
||||
if scanResult.Alert != nil {
|
||||
rules = scanResult.Alert.MatchedRules
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
|
||||
}
|
||||
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
|
||||
WithRules(rules...).
|
||||
WithCause(scanResult.BlockErr)
|
||||
}
|
||||
|
||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
||||
}
|
||||
@@ -61,7 +76,7 @@ func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
if len(recordIDs) == 1 && len(rows) == 1 {
|
||||
rowItems, _ := rows[0].([]interface{})
|
||||
@@ -78,7 +93,7 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -212,9 +213,12 @@ func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T)
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
|
||||
t.Fatalf("err=%v, want content safety exit error", err)
|
||||
var csErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &csErr) {
|
||||
t.Fatalf("err=%v, want typed content safety error", err)
|
||||
}
|
||||
if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" {
|
||||
t.Fatalf("rules=%v", csErr.Rules)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
||||
|
||||
@@ -49,7 +49,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
}
|
||||
if jsonRaw != "" {
|
||||
pc := newParseCtx(runtime)
|
||||
@@ -59,11 +59,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
}
|
||||
recordIDListValue, ok := body["record_id_list"]
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
return recordSelection{}, baseFlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
recordIDItems, ok := recordIDListValue.([]interface{})
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
return recordSelection{}, baseFlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDItems)
|
||||
if err != nil {
|
||||
@@ -117,14 +117,14 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
return nil, baseFlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
return nil, baseFlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
@@ -152,7 +152,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
|
||||
if opts.allowNil {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
return nil, baseFlagErrorf(opts.typeError)
|
||||
case []interface{}:
|
||||
rawItems = typed
|
||||
case []string:
|
||||
@@ -161,30 +161,30 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
|
||||
rawItems = append(rawItems, item)
|
||||
}
|
||||
default:
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
return nil, baseFlagErrorf(opts.typeError)
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
if opts.allowEmpty {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.emptyError)
|
||||
return nil, baseFlagErrorf(opts.emptyError)
|
||||
}
|
||||
if opts.max > 0 && len(rawItems) > opts.max {
|
||||
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
}
|
||||
seen := make(map[string]int, len(rawItems))
|
||||
result := make([]string, 0, len(rawItems))
|
||||
for index, value := range rawItems {
|
||||
item, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
return nil, baseFlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
}
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
return nil, baseFlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
}
|
||||
if first, exists := seen[item]; exists {
|
||||
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
return nil, baseFlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
}
|
||||
seen[item] = index + 1
|
||||
result = append(result, item)
|
||||
@@ -332,10 +332,10 @@ const maxShareBatchSize = 100
|
||||
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
if len(recordIDs) == 0 {
|
||||
return common.FlagErrorf("--record-ids is required and must not be empty")
|
||||
return baseFlagErrorf("--record-ids is required and must not be empty")
|
||||
}
|
||||
if len(recordIDs) > maxShareBatchSize {
|
||||
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||
return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,18 +71,18 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
|
||||
} else if obj, ok := value.(map[string]interface{}); ok {
|
||||
rawSortConfig, ok := obj["sort_config"]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
parsed, ok := rawSortConfig.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
|
||||
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label)
|
||||
}
|
||||
sortConfig = parsed
|
||||
} else {
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
if len(sortConfig) > recordSortMaxCount {
|
||||
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||
}
|
||||
return sortConfig, nil
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
|
||||
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -220,16 +220,16 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return common.FlagErrorf("--keyword is required unless --json is used")
|
||||
return baseFlagErrorf("--keyword is required unless --json is used")
|
||||
}
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
return common.FlagErrorf("--search-field is required unless --json is used")
|
||||
return baseFlagErrorf("--search-field is required unless --json is used")
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -225,7 +224,7 @@ func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
|
||||
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("name") {
|
||||
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
return baseFlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
}
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
@@ -245,9 +244,16 @@ func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
const outputDirRequired = "--output must be an existing directory when downloading multiple attachments or when --file-token is omitted"
|
||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, fileio.ErrPathValidation) {
|
||||
return baseValidationErrorf("unsafe output path: %s", statErr)
|
||||
}
|
||||
return baseFlagErrorf(outputDirRequired)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return baseFlagErrorf(outputDirRequired)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -269,7 +275,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
@@ -316,7 +322,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
@@ -353,7 +359,7 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
|
||||
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
failed := attachmentDownloadFailure(target, err)
|
||||
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
|
||||
return attachmentDownloadProgressError(runtime, err, downloaded, []map[string]interface{}{failed})
|
||||
}
|
||||
downloaded = append(downloaded, saved)
|
||||
}
|
||||
@@ -364,20 +370,20 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
|
||||
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return nil, output.ErrValidation("file operations require a FileIO provider")
|
||||
return nil, baseValidationErrorf("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
||||
return nil, baseValidationErrorf("unsafe file path: %s", err)
|
||||
}
|
||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
return nil, baseValidationErrorf("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
||||
return nil, baseValidationErrorf("file path is a directory: %s", filePath)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
return nil, baseValidationErrorf("file %s exceeds 2GB limit (size: %s)", filePath, common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
return fileInfo, nil
|
||||
}
|
||||
@@ -412,13 +418,13 @@ func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, e
|
||||
for index, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
return nil, baseFlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
}
|
||||
normalized = append(normalized, token)
|
||||
}
|
||||
normalized = dedupeStringsPreserveOrder(normalized)
|
||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
return nil, baseFlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -453,10 +459,10 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
||||
|
||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||
if len(recordIDs) == 0 {
|
||||
return nil, output.ErrValidation("provide at least one record id")
|
||||
return nil, baseValidationErrorf("provide at least one record id")
|
||||
}
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
return nil, baseValidationErrorf("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
||||
"record_id_list": recordIDs,
|
||||
@@ -560,14 +566,14 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return "", baseInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, readErr := f.Read(buf)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
return "", baseValidationErrorf("cannot read file: %s", readErr)
|
||||
}
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
@@ -617,11 +623,11 @@ type baseAttachmentDownloadTarget struct {
|
||||
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
||||
recordRaw, ok := attachments[recordID]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
return nil, baseValidationErrorf("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
}
|
||||
fields, ok := recordRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
return nil, baseValidationErrorf("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
}
|
||||
byToken := map[string]baseAttachmentDownloadItem{}
|
||||
fieldIDs := make([]string, 0, len(fields))
|
||||
@@ -633,12 +639,12 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
||||
rawList := fields[currentFieldID]
|
||||
items, ok := rawList.([]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
return nil, baseValidationErrorf("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
}
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
return nil, baseValidationErrorf("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
}
|
||||
fileToken, _ := item["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
@@ -668,7 +674,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
||||
result = append(result, item)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
|
||||
return nil, baseValidationErrorf("record %q has no attachments to download", recordID)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
||||
@@ -683,7 +689,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
||||
for _, token := range tokens {
|
||||
item, ok := byToken[token]
|
||||
if !ok {
|
||||
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
return nil, baseValidationErrorf("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
@@ -702,15 +708,15 @@ func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseA
|
||||
}
|
||||
resolved, err := runtime.ResolveSavePath(targetPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
||||
}
|
||||
if previous, exists := seen[resolved]; exists {
|
||||
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
return nil, baseValidationErrorf("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
}
|
||||
seen[resolved] = item
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
targets = append(targets, baseAttachmentDownloadTarget{
|
||||
@@ -776,7 +782,7 @@ func safeAttachmentFileTokenSuffix(fileToken string) string {
|
||||
|
||||
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
||||
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{}
|
||||
@@ -795,7 +801,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
||||
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
||||
@@ -803,7 +809,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
return nil, baseSaveError(err)
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
||||
if savedPath == "" {
|
||||
@@ -822,7 +828,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
|
||||
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
failure := map[string]interface{}{
|
||||
"record_id": target.Item.RecordID,
|
||||
"field_id": target.Item.FieldID,
|
||||
"file_token": target.Item.FileToken,
|
||||
@@ -831,72 +837,45 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m
|
||||
"resolved_path": target.ResolvedPath,
|
||||
"error": err.Error(),
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
failure["type"] = string(p.Category)
|
||||
failure["subtype"] = string(p.Subtype)
|
||||
if p.Code != 0 {
|
||||
failure["code"] = p.Code
|
||||
}
|
||||
if p.LogID != "" {
|
||||
failure["log_id"] = p.LogID
|
||||
}
|
||||
}
|
||||
return failure
|
||||
}
|
||||
|
||||
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
func attachmentDownloadProgressError(runtime *common.RuntimeContext, err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
||||
detail := map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"message": msg,
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
}
|
||||
const hint = "Some files may already have been saved. Inspect downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
||||
payload["hint"] = hint
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
payload["type"] = string(p.Category)
|
||||
payload["subtype"] = string(p.Subtype)
|
||||
if p.Code != 0 {
|
||||
payload["code"] = p.Code
|
||||
}
|
||||
}
|
||||
if logID := baseAttachmentDownloadLogID(err); logID != "" {
|
||||
detail["log_id"] = logID
|
||||
}
|
||||
const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
var netErr *errs.NetworkError
|
||||
if errors.As(err, &netErr) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitNetwork,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "network",
|
||||
Code: netErr.Code,
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitInternal,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "io",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
payload["log_id"] = logID
|
||||
}
|
||||
return runtime.OutPartialFailure(payload, nil)
|
||||
}
|
||||
|
||||
func baseAttachmentDownloadLogID(err error) string {
|
||||
var netErr *errs.NetworkError
|
||||
if errors.As(err, &netErr) {
|
||||
if id := strings.TrimSpace(netErr.LogID); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok {
|
||||
if logID, _ := detail["log_id"].(string); logID != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if logID := strings.TrimSpace(p.LogID); logID != "" {
|
||||
return logID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -5,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -117,7 +116,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
|
||||
for idx, item := range fieldItems {
|
||||
body, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("--fields item %d must be an object", idx+1)
|
||||
return baseValidationErrorf("--fields item %d must be an object", idx+1)
|
||||
}
|
||||
if idx == 0 && len(defaultFields) > 0 {
|
||||
fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body)
|
||||
|
||||
@@ -31,7 +31,7 @@ var BaseWorkflowCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
|
||||
@@ -27,10 +27,10 @@ var BaseWorkflowDisable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -28,10 +28,10 @@ var BaseWorkflowEnable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -30,10 +30,10 @@ var BaseWorkflowGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ var BaseWorkflowList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -33,10 +33,10 @@ var BaseWorkflowUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -29,7 +29,7 @@ const (
|
||||
|
||||
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
|
||||
if depth > 10 {
|
||||
return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "too many splits for instance_view")
|
||||
}
|
||||
if startTime > endTime {
|
||||
return nil, nil
|
||||
@@ -48,68 +48,67 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
|
||||
return append(left, right...), nil
|
||||
}
|
||||
|
||||
result, err := runtime.RawAPI("GET",
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)),
|
||||
map[string]interface{}{
|
||||
"start_time": fmt.Sprintf("%d", startTime),
|
||||
"end_time": fmt.Sprintf("%d", endTime),
|
||||
}, nil)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
|
||||
}
|
||||
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
|
||||
if code == 0 {
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
items, _ := data["items"].([]interface{})
|
||||
var events []map[string]interface{}
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
events = append(events, m)
|
||||
// CallAPITyped returns a typed error for any non-zero API code. The two
|
||||
// calendar instance_view limits (193103 time-range, 193104 too-many) are
|
||||
// recoverable by narrowing the window, so inspect the typed code and
|
||||
// recurse instead of treating them as fatal. Any other code falls through
|
||||
// to return the typed error unchanged.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
switch p.Code {
|
||||
case larkErrCalendarTimeRangeExceeded:
|
||||
mid := startTime + span/2
|
||||
if mid <= startTime {
|
||||
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
|
||||
"query failed: time range exceeds 40-day limit, please narrow the range").
|
||||
WithCode(larkErrCalendarTimeRangeExceeded)
|
||||
}
|
||||
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
|
||||
case larkErrCalendarTooManyInstances:
|
||||
if span <= minSplitWindowSeconds {
|
||||
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
|
||||
"query failed: more than 1000 instances in the time range, please narrow the range").
|
||||
WithCode(larkErrCalendarTooManyInstances)
|
||||
}
|
||||
mid := startTime + span/2
|
||||
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// Error 193103: time range exceeds limit -> split
|
||||
if int(code) == larkErrCalendarTimeRangeExceeded {
|
||||
mid := startTime + span/2
|
||||
if mid <= startTime {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range")
|
||||
items, _ := data["items"].([]interface{})
|
||||
var events []map[string]interface{}
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
events = append(events, m)
|
||||
}
|
||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(left, right...), nil
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// Error 193104: too many instances -> split
|
||||
if int(code) == larkErrCalendarTooManyInstances {
|
||||
if span <= minSplitWindowSeconds {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range")
|
||||
}
|
||||
mid := startTime + span/2
|
||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(left, right...), nil
|
||||
// fetchInstanceViewSplit halves [startTime, endTime] at mid and concatenates the
|
||||
// results of the two recursive sub-range queries. Shared by the 193103/193104
|
||||
// split paths.
|
||||
func fetchInstanceViewSplit(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, mid, endTime int64, depth int) ([]map[string]interface{}, error) {
|
||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg, _ := resultMap["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), msg, resultMap["error"])
|
||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(left, right...), nil
|
||||
}
|
||||
|
||||
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
|
||||
@@ -147,20 +146,20 @@ func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) {
|
||||
|
||||
startTime, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return 0, 0, output.ErrValidation("--start: %v", err)
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
endTime, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return 0, 0, output.ErrValidation("--end: %v", err)
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
|
||||
startInt, err := strconv.ParseInt(startTime, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, output.ErrValidation("invalid start time: %v", err)
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
}
|
||||
endInt, err := strconv.ParseInt(endTime, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, output.ErrValidation("invalid end time: %v", err)
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
}
|
||||
|
||||
return startInt, endInt, nil
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -60,7 +61,7 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported attendee id format: %s", id)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported attendee id format: %s", id)
|
||||
}
|
||||
}
|
||||
return attendees, nil
|
||||
@@ -89,8 +90,8 @@ var CalendarCreate = common.Shortcut{
|
||||
}
|
||||
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
|
||||
if val := runtime.Str(flag); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,35 +103,35 @@ var CalendarCreate = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
||||
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--attendee-ids")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.Str("start") == "" {
|
||||
return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --start (e.g. '2026-03-12T14:00+08:00')").WithParam("--start")
|
||||
}
|
||||
if runtime.Str("end") == "" {
|
||||
return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end")
|
||||
}
|
||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--start: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--end: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
s, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("invalid start time: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
}
|
||||
e, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("invalid end time: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
}
|
||||
if e <= s {
|
||||
return common.FlagErrorf("end time must be after start time")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -183,27 +184,26 @@ var CalendarCreate = common.Shortcut{
|
||||
|
||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||
if err != nil {
|
||||
return output.ErrValidation("--start: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||
if err != nil {
|
||||
return output.ErrValidation("--end: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
|
||||
// Create event
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
|
||||
nil, eventData)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, _ := data["event"].(map[string]interface{})
|
||||
eventId, _ := event["event_id"].(string)
|
||||
if eventId == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create event: no event_id returned")
|
||||
}
|
||||
|
||||
// Add attendees if specified
|
||||
@@ -214,27 +214,25 @@ var CalendarCreate = common.Shortcut{
|
||||
}
|
||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid attendee id: %v", err)
|
||||
return withParam(err, "--attendee-ids")
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPI("POST",
|
||||
_, err = runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
map[string]interface{}{"user_id_type": "open_id"},
|
||||
map[string]interface{}{
|
||||
"attendees": attendees,
|
||||
"need_notification": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
// Rollback: delete the event
|
||||
_, rollbackErr := runtime.RawAPI("DELETE",
|
||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
map[string]interface{}{"need_notification": false}, nil)
|
||||
rollbackErr = wrapPredefinedError(rollbackErr)
|
||||
if rollbackErr != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
||||
return withStepContext(err, "rollback also failed (%v); orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err)
|
||||
return withStepContext(err, "event rolled back successfully")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -20,20 +21,20 @@ func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, err
|
||||
|
||||
startTs, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
endTs, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
|
||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
}
|
||||
|
||||
timeMin := time.Unix(startSec, 0).Format(time.RFC3339)
|
||||
@@ -73,13 +74,13 @@ var CalendarFreebusy = common.Shortcut{
|
||||
}
|
||||
userId := runtime.Str("user-id")
|
||||
if userId == "" && runtime.IsBot() {
|
||||
return common.FlagErrorf("--user-id is required for bot identity")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required for bot identity").WithParam("--user-id")
|
||||
}
|
||||
if userId == "" && runtime.UserOpenId() == "" {
|
||||
return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot determine user ID, specify --user-id or ensure you are logged in").WithParam("--user-id")
|
||||
}
|
||||
if userId != "" {
|
||||
if _, err := common.ValidateUserID(userId); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userId); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -93,16 +94,17 @@ var CalendarFreebusy = common.Shortcut{
|
||||
|
||||
timeMin, timeMax, err := parseFreebusyTimeRange(runtime)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--start/--end: %v", err)
|
||||
// parseFreebusyTimeRange already returns a typed *errs.ValidationError
|
||||
// carrying the offending flag in .Param; pass it through unchanged.
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
|
||||
"time_min": timeMin,
|
||||
"time_max": timeMax,
|
||||
"user_id": userId,
|
||||
"need_rsvp_status": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -126,40 +126,40 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
|
||||
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
|
||||
rawSlots := runtime.StrArray(flagSlot)
|
||||
if len(rawSlots) == 0 {
|
||||
return nil, output.ErrValidation("specify at least one --slot")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one --slot").WithParam("--slot")
|
||||
}
|
||||
slots := make([]roomFindSlot, 0, len(rawSlots))
|
||||
for _, raw := range rawSlots {
|
||||
parts := strings.Split(strings.TrimSpace(raw), "~")
|
||||
if len(parts) != 2 {
|
||||
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --slot format %q, expected start~end", raw).WithParam("--slot")
|
||||
}
|
||||
startTs, err := common.ParseTime(parts[0])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start time %q: %v", parts[0], err).WithParam("--slot")
|
||||
}
|
||||
endTs, err := common.ParseTime(parts[1])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end time %q: %v", parts[1], err).WithParam("--slot")
|
||||
}
|
||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
|
||||
}
|
||||
if endSec <= startSec {
|
||||
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slot end time must be after start time: %q", raw).WithParam("--slot")
|
||||
}
|
||||
startRFC3339, err := unixStringToRFC3339(startTs)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
|
||||
}
|
||||
endRFC3339, err := unixStringToRFC3339(endTs)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
|
||||
}
|
||||
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string
|
||||
seenChats[id] = true
|
||||
}
|
||||
default:
|
||||
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
|
||||
}
|
||||
}
|
||||
if currentUserID != "" && !seenUsers[currentUserID] {
|
||||
@@ -249,20 +249,19 @@ func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*room
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.WrapInternal(err)
|
||||
}
|
||||
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp = &OpenAPIResponse[*roomFindData]{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
||||
}
|
||||
|
||||
if resp.Code != 0 {
|
||||
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
|
||||
}
|
||||
|
||||
if resp.Data != nil {
|
||||
@@ -317,8 +316,8 @@ var CalendarRoomFind = common.Shortcut{
|
||||
}
|
||||
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,8 +326,8 @@ var CalendarRoomFind = common.Shortcut{
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if err := common.RejectDangerousChars("--"+flagRoomName, name); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
if err := common.RejectDangerousCharsTyped("--"+flagRoomName, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := parseRoomFindSlots(runtime); err != nil {
|
||||
@@ -338,13 +337,13 @@ var CalendarRoomFind = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
|
||||
return output.ErrValidation("--min-capacity must be >= 0")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be >= 0").WithParam("--min-capacity")
|
||||
}
|
||||
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
|
||||
return output.ErrValidation("--max-capacity must be >= 0")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-capacity must be >= 0").WithParam("--max-capacity")
|
||||
}
|
||||
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
|
||||
return output.ErrValidation("--min-capacity must be <= --max-capacity")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be <= --max-capacity").WithParam("--min-capacity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -51,15 +51,15 @@ var CalendarRsvp = common.Shortcut{
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return output.ErrValidation("event-id cannot be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -71,7 +71,7 @@ var CalendarRsvp = common.Shortcut{
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
status := strings.TrimSpace(runtime.Str("rsvp-status"))
|
||||
|
||||
_, err := runtime.DoAPIJSON("POST",
|
||||
_, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -70,11 +70,11 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
|
||||
timeMin, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid --start: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --start: %v", err).WithParam("--start")
|
||||
}
|
||||
minSec, err := strconv.ParseInt(timeMin, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid start timestamp: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
}
|
||||
startTime := time.Unix(minSec, 0)
|
||||
|
||||
@@ -87,12 +87,12 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
|
||||
timeMax, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid --end: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --end: %v", err).WithParam("--end")
|
||||
}
|
||||
// Convert Unix timestamp string back to RFC3339 since the API requires RFC3339
|
||||
maxSec, err := strconv.ParseInt(timeMax, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid end timestamp: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
}
|
||||
req.SearchStartTime = startTime.Format(time.RFC3339)
|
||||
req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339)
|
||||
@@ -157,23 +157,23 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
}
|
||||
parts := strings.Split(r, "~")
|
||||
if len(parts) != 2 {
|
||||
return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --exclude format %q, expected 'start~end'", r).WithParam("--exclude")
|
||||
}
|
||||
startTsStr, err := common.ParseTime(parts[0])
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
|
||||
}
|
||||
endTsStr, err := common.ParseTime(parts[1], "end")
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
|
||||
}
|
||||
startSec, err := strconv.ParseInt(startTsStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp in --exclude: %v", err).WithParam("--exclude")
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTsStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp in --exclude: %v", err).WithParam("--exclude")
|
||||
}
|
||||
excludedTimes = append(excludedTimes, &EventTime{
|
||||
EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339),
|
||||
@@ -219,13 +219,13 @@ var CalendarSuggestion = common.Shortcut{
|
||||
}
|
||||
durationMinutes := runtime.Int(flagDurationMinutes)
|
||||
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
|
||||
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440").WithParam("--duration-minutes")
|
||||
}
|
||||
|
||||
for _, flag := range []string{flagEventRrule, flagTimezone} {
|
||||
if val := runtime.Str(flag); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,7 @@ var CalendarSuggestion = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") {
|
||||
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,14 +245,14 @@ var CalendarSuggestion = common.Shortcut{
|
||||
startInput := runtime.Str(flagStart)
|
||||
if startInput != "" {
|
||||
if _, err := common.ParseTime(startInput); err != nil {
|
||||
return output.ErrValidation("invalid start time: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
}
|
||||
}
|
||||
|
||||
endInput := runtime.Str(flagEnd)
|
||||
if endInput != "" {
|
||||
if _, err := common.ParseTime(endInput, "end"); err != nil {
|
||||
return output.ErrValidation("invalid end time: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,13 +267,13 @@ var CalendarSuggestion = common.Shortcut{
|
||||
}
|
||||
parts := strings.Split(r, "~")
|
||||
if len(parts) != 2 {
|
||||
return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid range format in --exclude: %q, expect start~end", r).WithParam("--exclude")
|
||||
}
|
||||
if _, err := common.ParseTime(parts[0]); err != nil {
|
||||
return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
|
||||
}
|
||||
if _, err := common.ParseTime(parts[1], "end"); err != nil {
|
||||
return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,20 +292,19 @@ var CalendarSuggestion = common.Shortcut{
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resp = &OpenAPIResponse[*SuggestionResponse]{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
||||
}
|
||||
|
||||
if resp.Code != 0 {
|
||||
return output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
|
||||
}
|
||||
|
||||
data := resp.Data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user