mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 14:38:53 +08:00
Compare commits
14 Commits
codex/insp
...
feat/errs-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78e9d4c597 | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
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)
|
||||
|
||||
@@ -64,8 +64,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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -370,3 +371,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,20 @@ import (
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -18,14 +18,20 @@ import (
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/wiki/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -691,7 +691,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -907,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -944,11 +944,19 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/calendar/calendar_create.go",
|
||||
"shortcuts/contact/contact_search_user.go",
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/im/im_messages_send.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/sheets/helpers.go",
|
||||
"shortcuts/slides/slides_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
"shortcuts/wiki/wiki_node_get.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -997,6 +1005,34 @@ func boom() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversCCMPathsWithAliasAndFunctionValue(t *testing.T) {
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/sheets/helpers.go",
|
||||
"shortcuts/slides/slides_create.go",
|
||||
"shortcuts/wiki/wiki_node_get.go",
|
||||
}
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
for _, path := range paths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
v := CheckNoLegacyCommonHelperCall(path, src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on %s, got %d: %+v", path, len(v), v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
|
||||
@@ -1006,7 +1042,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -676,30 +676,10 @@ func WrapInputStatErrorTyped(err error, readMsg ...string) error {
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
||||
// using standardized messages and the given error category (e.g. "api_error", "io").
|
||||
// Path validation errors always use ErrValidation (exit code 2).
|
||||
//
|
||||
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
|
||||
func WrapSaveErrorByCategory(err error, category string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
case errors.As(err, &me):
|
||||
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
|
||||
default:
|
||||
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
|
||||
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
|
||||
// "internal" wire type: call sites migrating from a custom category
|
||||
// (e.g. "io", "api_error") change their envelope's type field.
|
||||
// Non-path failures always emit the canonical "internal" wire type: call sites
|
||||
// migrating from a custom legacy category (e.g. "io", "api_error") change
|
||||
// their envelope's type field.
|
||||
func WrapSaveErrorTyped(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -6,24 +6,8 @@ package common
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ResolveOpenIDs expands the special identifier "me" to the current user's
|
||||
// open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName is
|
||||
// used in error messages to point the user at the offending CLI flag.
|
||||
//
|
||||
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
|
||||
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, output.ErrValidation("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveOpenIDsTyped expands the special identifier "me" to the current
|
||||
// user's open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName names
|
||||
|
||||
@@ -17,9 +17,9 @@ func resolveOpenIDsTestRuntime(userOpenID string) *RuntimeContext {
|
||||
return TestNewRuntimeContext(cmd, cfg)
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_Empty(t *testing.T) {
|
||||
func TestResolveOpenIDsTyped_Empty(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDs("--user-ids", nil, rt)
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", nil, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -28,21 +28,9 @@ func TestResolveOpenIDs_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_ExpandsMeAndDedups(t *testing.T) {
|
||||
func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDs("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"ou_self", "ou_a"}
|
||||
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
|
||||
t.Fatalf("got %v, want %v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDs("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -52,22 +40,11 @@ func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_MeWithoutLogin(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("")
|
||||
_, err := ResolveOpenIDs("--user-ids", []string{"me"}, rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--user-ids") {
|
||||
t.Fatalf("error should mention the offending flag name; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
|
||||
func TestResolveOpenIDsTyped_DedupIsCaseInsensitive(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
// Same underlying open_id with three case variants — should collapse to
|
||||
// one entry, preserving the first-occurrence form.
|
||||
out, err := ResolveOpenIDs("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
@@ -42,17 +40,6 @@ func normalizeChatID(input string) (string, string) {
|
||||
return input, ""
|
||||
}
|
||||
|
||||
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
|
||||
//
|
||||
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
|
||||
func ValidateUserID(input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
|
||||
// param names the flag being validated (e.g. "--creator-ids") and is
|
||||
// recorded on the typed error.
|
||||
|
||||
78
shortcuts/contact/contact_errors.go
Normal file
78
shortcuts/contact/contact_errors.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const contactFanoutRetryHint = "retry the command; if it persists, narrow --queries to a single term to isolate the failing input"
|
||||
|
||||
func contactInvalidResponseError(format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
|
||||
}
|
||||
|
||||
func contactFanoutErrorSummary(err error) string {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Code >= 100 && p.Code < 600 {
|
||||
prefix := fmt.Sprintf("HTTP %d:", p.Code)
|
||||
body := strings.TrimSpace(strings.TrimPrefix(p.Message, prefix))
|
||||
msg := fmt.Sprintf("HTTP %d %s", p.Code, http.StatusText(p.Code))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, contactTruncateError(body, 200))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
if p.Code != 0 {
|
||||
return fmt.Sprintf("API %d: %s", p.Code, p.Message)
|
||||
}
|
||||
return p.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// contactFanoutAllFailedError builds the top-level error returned when every
|
||||
// fanout query fails. It mirrors the representative (first) failure's
|
||||
// classification — category, subtype, code, log_id, retryable, hint — so the
|
||||
// exit-code classifier still sees the real signal, while carrying the aggregate
|
||||
// message. The representative error is copied (never mutated) and kept as the
|
||||
// cause, so a single-query problem object is not rewritten into an aggregate one.
|
||||
func contactFanoutAllFailedError(err error, msg string) error {
|
||||
var (
|
||||
apiErr *errs.APIError
|
||||
netErr *errs.NetworkError
|
||||
intErr *errs.InternalError
|
||||
)
|
||||
switch {
|
||||
case errors.As(err, &apiErr):
|
||||
c := *apiErr
|
||||
c.Message = msg
|
||||
c.Cause = err
|
||||
return &c
|
||||
case errors.As(err, &netErr):
|
||||
c := *netErr
|
||||
c.Message = msg
|
||||
c.Cause = err
|
||||
return &c
|
||||
case errors.As(err, &intErr):
|
||||
c := *intErr
|
||||
c.Message = msg
|
||||
c.Cause = err
|
||||
return &c
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithHint(contactFanoutRetryHint).WithCause(err)
|
||||
}
|
||||
|
||||
func contactTruncateError(s string, maxRunes int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxRunes]) + "..."
|
||||
}
|
||||
81
shortcuts/contact/contact_errors_test.go
Normal file
81
shortcuts/contact/contact_errors_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestContactFanoutErrorSummary_HTTPStatus(t *testing.T) {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkServer, `HTTP 503: {"reason":"upstream_unavailable"}`).
|
||||
WithCode(503).
|
||||
WithRetryable()
|
||||
|
||||
got := contactFanoutErrorSummary(err)
|
||||
if !strings.HasPrefix(got, "HTTP 503 Service Unavailable: ") {
|
||||
t.Fatalf("summary: got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "upstream_unavailable") {
|
||||
t.Fatalf("summary should include truncated body details, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactInvalidResponseError_TypedInternal(t *testing.T) {
|
||||
got := contactInvalidResponseError("decode contact response failed")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactFanoutAllFailedError_PreservesTypedProblem(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").
|
||||
WithCode(99991663).
|
||||
WithLogID("log-contact-1").
|
||||
WithRetryable()
|
||||
|
||||
got := contactFanoutAllFailedError(err, "all 2 queries failed; first: API 99991663: rate limit (query=\"alice\")")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
if p.Code != 99991663 || p.LogID != "log-contact-1" || !p.Retryable {
|
||||
t.Fatalf("problem metadata not preserved: %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Message, "all 2 queries failed") {
|
||||
t.Fatalf("problem message not decorated: %q", p.Message)
|
||||
}
|
||||
// The representative error must not be mutated: it stays a single-query
|
||||
// failure, while the aggregate is a distinct value carrying it as cause.
|
||||
if err.Message != "rate limit" {
|
||||
t.Fatalf("representative error message was mutated: %q", err.Message)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatalf("aggregate error should keep the representative failure as its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactFanoutAllFailedError_UntypedGetsActionableHint(t *testing.T) {
|
||||
got := contactFanoutAllFailedError(nil, "all 2 queries failed; first: internal error (query=\"alice\")")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "narrow --queries") {
|
||||
t.Fatalf("hint should guide recovery, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ var ContactGetUser = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("user-id") == "" && runtime.IsBot() {
|
||||
return common.FlagErrorf("bot identity cannot get current user info, specify --user-id")
|
||||
return common.ValidationErrorf("bot identity cannot get current user info, specify --user-id").
|
||||
WithParam("--user-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -63,7 +64,7 @@ var ContactGetUser = common.Shortcut{
|
||||
|
||||
if userId == "" {
|
||||
// Current user
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/authen/v1/user_info", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +88,7 @@ var ContactGetUser = common.Shortcut{
|
||||
|
||||
if runtime.IsBot() {
|
||||
// Bot identity: GET /contact/v3/users/:user_id (full profile)
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
|
||||
map[string]interface{}{"user_id_type": userIdType}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -110,7 +111,7 @@ var ContactGetUser = common.Shortcut{
|
||||
}
|
||||
|
||||
// User identity: POST /contact/v3/users/basic_batch (lightweight)
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch",
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/contact/v3/users/basic_batch",
|
||||
map[string]interface{}{"user_id_type": userIdType},
|
||||
map[string]interface{}{"user_ids": []string{userId}})
|
||||
if err != nil {
|
||||
|
||||
125
shortcuts/contact/contact_get_user_test.go
Normal file
125
shortcuts/contact/contact_get_user_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestGetUser_BotCurrentUserValidationTyped(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
|
||||
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validation.Param != "--user-id" {
|
||||
t.Fatalf("param: got %q, want --user-id", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_DryRunShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "current user",
|
||||
args: []string{"+get-user", "--dry-run", "--as", "user"},
|
||||
want: []string{"GET", "/authen/v1/user_info", "current_user"},
|
||||
},
|
||||
{
|
||||
name: "bot specific user",
|
||||
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "bot"},
|
||||
want: []string{"GET", "/contact/v3/users/ou_a", "ou_a", "open_id"},
|
||||
},
|
||||
{
|
||||
name: "user basic batch",
|
||||
args: []string{"+get-user", "--user-id", "ou_a", "--dry-run", "--as", "user"},
|
||||
want: []string{"POST", "/contact/v3/users/basic_batch", "ou_a", "open_id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
if err := mountAndRun(t, ContactGetUser, tc.args, f, stdout); err != nil {
|
||||
t.Fatalf("dry-run: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range tc.want {
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(want)) {
|
||||
t.Fatalf("dry-run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_CurrentUserAPIFailureTyped(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/authen/v1/user_info",
|
||||
Body: map[string]interface{}{"code": 123456, "msg": "upstream rejected contact request"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected API error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Code != 123456 {
|
||||
t.Fatalf("code: got %d, want 123456", p.Code)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should stay empty on API failure, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_UserBasicBatchUsesTypedAPI(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/contact/v3/users/basic_batch?user_id_type=open_id",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"users": []interface{}{
|
||||
map[string]interface{}{"user_id": "ou_a", "name": "Alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactGetUser, []string{"+get-user", "--user-id", "ou_a", "--as", "user", "--format", "json"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !bytes.Contains(stub.CapturedBody, []byte(`"ou_a"`)) {
|
||||
t.Fatalf("request body should include user id, got %s", string(stub.CapturedBody))
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"user"`)) {
|
||||
t.Fatalf("stdout should include user object, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -80,12 +81,6 @@ type searchUserAPIFilter struct {
|
||||
HasEnterpriseEmail bool `json:"has_enterprise_email,omitempty"`
|
||||
}
|
||||
|
||||
type searchUserAPIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *searchUserAPIData `json:"data"`
|
||||
}
|
||||
|
||||
type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
@@ -216,19 +211,17 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
return output.ErrAPI(apiResp.StatusCode, http.StatusText(apiResp.StatusCode), string(apiResp.RawBody))
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respData, err := decodeSearchUserAPIData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response failed", err.Error())
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return output.ErrAPI(resp.Code, resp.Msg, string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
@@ -245,6 +238,20 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeSearchUserAPIData(data map[string]interface{}) (*searchUserAPIData, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, contactInvalidResponseError("marshal search user response data failed").
|
||||
WithCause(err)
|
||||
}
|
||||
var out searchUserAPIData
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, contactInvalidResponseError("decode search user response data failed").
|
||||
WithCause(err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func isHumanReadableFormat(format string) bool {
|
||||
return format == "pretty" || format == "table"
|
||||
}
|
||||
@@ -373,52 +380,74 @@ func rowFromItem(item *searchUserAPIItem, lang string, brand core.LarkBrand) sea
|
||||
|
||||
func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
if !hasAnySearchInput(runtime) {
|
||||
return common.FlagErrorf(
|
||||
return common.ValidationErrorf(
|
||||
"specify at least one of --query, --queries, --user-ids, --has-chatted, --has-enterprise-email, --exclude-external-users, --left-organization",
|
||||
).WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--user-ids", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--has-chatted", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--has-enterprise-email", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--exclude-external-users", Reason: "required; specify at least one search input"},
|
||||
errs.InvalidParam{Name: "--left-organization", Reason: "required; specify at least one search input"},
|
||||
)
|
||||
}
|
||||
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
if queriesRaw != "" {
|
||||
if strings.TrimSpace(runtime.Str("query")) != "" {
|
||||
return common.FlagErrorf("--query and --queries are mutually exclusive")
|
||||
return common.ValidationErrorf("--query and --queries are mutually exclusive").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("user-ids")) != "" {
|
||||
return common.FlagErrorf("--user-ids and --queries are mutually exclusive")
|
||||
return common.ValidationErrorf("--user-ids and --queries are mutually exclusive").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--user-ids", Reason: "mutually exclusive with --queries"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --user-ids"},
|
||||
)
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.FlagErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw)
|
||||
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
|
||||
WithParam("--queries")
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.FlagErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries))
|
||||
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
|
||||
WithParam("--queries")
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars)
|
||||
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxSearchUserQueryChars).
|
||||
WithParam("--queries")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
|
||||
if utf8.RuneCountInString(q) > maxSearchUserQueryChars {
|
||||
return common.FlagErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars)
|
||||
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxSearchUserQueryChars).
|
||||
WithParam("--query")
|
||||
}
|
||||
}
|
||||
|
||||
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
|
||||
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
|
||||
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return common.FlagErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw)
|
||||
return common.ValidationErrorf("--user-ids: no valid open_id parsed from %q (separate entries with ',')", raw).
|
||||
WithParam("--user-ids")
|
||||
}
|
||||
if len(ids) > maxSearchUserUserIDs {
|
||||
return common.FlagErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs)
|
||||
return common.ValidationErrorf("--user-ids: must be at most %d entries", maxSearchUserUserIDs).
|
||||
WithParam("--user-ids")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--user-ids", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -429,15 +458,16 @@ func validateSearchUser(runtime *common.RuntimeContext) error {
|
||||
// silent wrong-result bugs.
|
||||
for _, bf := range searchUserBoolFilters {
|
||||
if runtime.Cmd.Flags().Changed(bf.Flag) && !runtime.Bool(bf.Flag) {
|
||||
return common.FlagErrorf(
|
||||
return common.ValidationErrorf(
|
||||
"--%s: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
|
||||
bf.Flag,
|
||||
)
|
||||
).WithParam("--" + bf.Flag)
|
||||
}
|
||||
}
|
||||
|
||||
if n := runtime.Int("page-size"); n < 1 || n > maxSearchUserPageSize {
|
||||
return common.FlagErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize)
|
||||
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxSearchUserPageSize).
|
||||
WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -473,7 +503,7 @@ func buildSearchUserBody(runtime *common.RuntimeContext) (*searchUserAPIRequest,
|
||||
hasFilter := false
|
||||
|
||||
if raw := strings.TrimSpace(runtime.Str("user-ids")); raw != "" {
|
||||
ids, err := common.ResolveOpenIDs("--user-ids", common.SplitCSV(raw), runtime)
|
||||
ids, err := common.ResolveOpenIDsTyped("--user-ids", common.SplitCSV(raw), runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -47,7 +46,7 @@ type fanoutResult struct {
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
ErrMsg string // empty = success
|
||||
ErrCode int // 0 = success or unknown; otherwise an HTTP status or Lark API code corresponding to the first error
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
@@ -67,7 +66,7 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
// request; in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
return fanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
body := &searchUserAPIRequest{Query: query}
|
||||
@@ -82,38 +81,29 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: err.Error()}
|
||||
}
|
||||
if apiResp.StatusCode != http.StatusOK {
|
||||
body := strings.TrimSpace(string(apiResp.RawBody))
|
||||
const maxBody = 200
|
||||
if len(body) > maxBody {
|
||||
body = body[:maxBody] + "..."
|
||||
}
|
||||
msg := fmt.Sprintf("HTTP %d %s", apiResp.StatusCode, http.StatusText(apiResp.StatusCode))
|
||||
if body != "" {
|
||||
msg = fmt.Sprintf("%s: %s", msg, body)
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: msg,
|
||||
ErrCode: apiResp.StatusCode}
|
||||
return fanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
var resp searchUserAPIEnvelope
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("parse response failed: %v", err)}
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fanoutResult{Index: index, Query: query,
|
||||
ErrMsg: fmt.Sprintf("API %d: %s", resp.Code, resp.Msg),
|
||||
ErrCode: resp.Code}
|
||||
respData, err := decodeSearchUserAPIData(data)
|
||||
if err != nil {
|
||||
return fanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(resp.Data, runtime.Str("lang"), runtime.Config.Brand)
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
}
|
||||
return fanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
|
||||
}
|
||||
|
||||
type fanoutUser struct {
|
||||
searchUser
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
@@ -146,7 +136,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErrCode int
|
||||
var firstErr error
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
@@ -158,7 +148,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErrCode = r.ErrCode
|
||||
firstErr = r.Err
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -169,18 +159,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
// Only the HTTP-status / Lark-API-code branches in runOneQuery populate
|
||||
// ErrCode; transport, parse, panic, and ctx-canceled stay at 0. Code 0
|
||||
// means success in the Lark protocol, so don't pretend it's an API error
|
||||
// when we have nothing structured to report.
|
||||
if firstErrCode != 0 {
|
||||
return nil, output.ErrAPI(firstErrCode, msg, "")
|
||||
}
|
||||
// No structured API code — the failure was transport, parse, panic, or
|
||||
// cancellation. Suggest the actionable next step rather than shipping
|
||||
// an empty hint that would leave the calling agent with nothing to do.
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg,
|
||||
"retry the command; if it persists, narrow --queries to a single term to isolate the failing input")
|
||||
return nil, contactFanoutAllFailedError(firstErr, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -16,10 +15,10 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -254,6 +253,16 @@ func TestRowFromItem_CrossTenantEmptyEmailNoPanic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectUsers_NilData(t *testing.T) {
|
||||
users, hasMore := projectUsers(nil, "", core.BrandFeishu)
|
||||
if users == nil {
|
||||
t.Fatalf("users should be an empty slice, not nil")
|
||||
}
|
||||
if len(users) != 0 || hasMore {
|
||||
t.Fatalf("projectUsers(nil): got users=%v hasMore=%v", users, hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSearchUser_AllEmpty_Errors(t *testing.T) {
|
||||
cmd := newSearchUserTestCommand()
|
||||
rt := common.TestNewRuntimeContext(cmd, searchUserDefaultConfig())
|
||||
@@ -479,6 +488,26 @@ func TestBuildBody_UserIDsResolveAndDedup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBody_UserIDsMeWithoutLoginReturnsTypedError(t *testing.T) {
|
||||
cmd := newSearchUserTestCommand()
|
||||
_ = cmd.Flags().Set("user-ids", "me")
|
||||
cfg := searchUserDefaultConfig()
|
||||
cfg.UserOpenId = ""
|
||||
rt := common.TestNewRuntimeContext(cmd, cfg)
|
||||
|
||||
body, err := buildSearchUserBody(rt)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got body %+v", body)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSearchUser_PageSizeOutOfRange_Errors(t *testing.T) {
|
||||
for _, n := range []int{0, 31} {
|
||||
cmd := newSearchUserTestCommand()
|
||||
@@ -504,6 +533,20 @@ func TestValidateSearchUser_PageSizeBoundaries_OK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
|
||||
_, err := decodeSearchUserAPIData(map[string]interface{}{"bad": func() {}})
|
||||
if err == nil {
|
||||
t.Fatalf("expected marshal failure")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it
|
||||
// with the given args. Mirrors the pattern used in other shortcut packages.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
@@ -1011,6 +1054,13 @@ func TestRunOneQuery_APINonZeroCode(t *testing.T) {
|
||||
if got.ErrMsg != "API 99991663: rate limited" {
|
||||
t.Errorf("ErrMsg = %q, want 'API 99991663: rate limited'", got.ErrMsg)
|
||||
}
|
||||
p, ok := errs.ProblemOf(got.Err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
|
||||
}
|
||||
if p.Code != 99991663 {
|
||||
t.Errorf("problem code: got %d, want 99991663", p.Code)
|
||||
}
|
||||
if got.Users != nil || got.HasMore {
|
||||
t.Errorf("on error, Users/HasMore must be zero values; got %+v", got)
|
||||
}
|
||||
@@ -1032,8 +1082,15 @@ func TestRunOneQuery_HTTPNon200(t *testing.T) {
|
||||
if !strings.Contains(got.ErrMsg, "upstream_unavailable") {
|
||||
t.Errorf("ErrMsg should include response body for diagnosis; got %q", got.ErrMsg)
|
||||
}
|
||||
if got.ErrCode != 503 {
|
||||
t.Errorf("ErrCode = %d, want 503", got.ErrCode)
|
||||
p, ok := errs.ProblemOf(got.Err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem on fanout result, got %T", got.Err)
|
||||
}
|
||||
if p.Code != 503 {
|
||||
t.Errorf("problem code: got %d, want 503", p.Code)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("problem category: got %q, want %q", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,6 +1137,16 @@ func TestRunOneQuery_TransportError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutErrorResult_NilErrorIsSuccess(t *testing.T) {
|
||||
got := fanoutErrorResult(4, "alice", nil)
|
||||
if got.Index != 4 || got.Query != "alice" {
|
||||
t.Fatalf("Index/Query mismatch: %+v", got)
|
||||
}
|
||||
if got.ErrMsg != "" || got.Err != nil {
|
||||
t.Fatalf("nil error should produce a success result, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_OrderAndShape(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 1, Query: "bob", Users: []searchUser{{OpenID: "ou_b"}}, HasMore: true},
|
||||
@@ -1136,7 +1203,7 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) {
|
||||
}
|
||||
|
||||
// When all queries fail with no structured Lark API code (transport, parse,
|
||||
// panic, ctx-canceled), the returned ExitError must carry an actionable
|
||||
// panic, ctx-canceled), the returned typed error must carry an actionable
|
||||
// hint so the calling agent has a next step to try instead of giving up.
|
||||
func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
@@ -1147,28 +1214,38 @@ func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when all queries failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected Detail, got nil")
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("category: got %q, want %q", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if p.Hint == "" {
|
||||
t.Errorf("expected non-empty Hint so agents have a next step; got empty")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "retry") {
|
||||
t.Errorf("hint should suggest retry as the first action; got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Codes from the first failure must propagate through output.ErrAPI so the
|
||||
// CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// Codes from the first failure must propagate through typed problem fields so
|
||||
// the CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit)
|
||||
// instead of 0, which would mean "success" in the Lark protocol.
|
||||
func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
|
||||
results := []fanoutResult{
|
||||
{Index: 0, Query: "alice", ErrMsg: "API 99991663: rate limit", ErrCode: 99991663},
|
||||
{Index: 1, Query: "bob", ErrMsg: "HTTP 500", ErrCode: 500},
|
||||
{
|
||||
Index: 0,
|
||||
Query: "alice",
|
||||
ErrMsg: "API 99991663: rate limit",
|
||||
Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663),
|
||||
},
|
||||
{
|
||||
Index: 1,
|
||||
Query: "bob",
|
||||
ErrMsg: "HTTP 500",
|
||||
Err: errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500").WithCode(500),
|
||||
},
|
||||
}
|
||||
_, err := buildFanoutResponse([]string{"alice", "bob"}, results)
|
||||
if err == nil {
|
||||
@@ -1177,6 +1254,16 @@ func TestFanoutAssemble_AllFailed_PropagatesFirstCode(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "rate limit") {
|
||||
t.Errorf("error should contain first ErrMsg; got %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", err)
|
||||
}
|
||||
if p.Code != 99991663 {
|
||||
t.Errorf("problem code: got %d, want 99991663", p.Code)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("problem subtype: got %q, want %q", p.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAssemble_PartialFailureOK(t *testing.T) {
|
||||
@@ -1220,6 +1307,37 @@ func TestFanoutAssemble_NoTopLevelHasMore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyFanoutUserRows(t *testing.T) {
|
||||
rows := prettyFanoutUserRows([]fanoutUser{
|
||||
{
|
||||
searchUser: searchUser{
|
||||
OpenID: "ou_a",
|
||||
LocalizedName: "Alice",
|
||||
Department: strings.Repeat("d", 80),
|
||||
EnterpriseEmail: "alice@example.com",
|
||||
HasChatted: true,
|
||||
ChatRecencyHint: "Contacted yesterday",
|
||||
},
|
||||
MatchedQuery: "alice",
|
||||
},
|
||||
})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows: got %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
for _, key := range []string{"matched_query", "localized_name", "department", "enterprise_email", "has_chatted", "chat_recency_hint", "open_id"} {
|
||||
if _, ok := row[key]; !ok {
|
||||
t.Fatalf("row missing key %q: %+v", key, row)
|
||||
}
|
||||
}
|
||||
if row["matched_query"] != "alice" || row["open_id"] != "ou_a" {
|
||||
t.Fatalf("row identity fields: %+v", row)
|
||||
}
|
||||
if len(row["department"].(string)) >= 80 {
|
||||
t.Fatalf("department should be truncated for table display, got %q", row["department"])
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies that with the auto-pagination flags removed, --page-all / --page-limit
|
||||
// are no longer accepted. cobra must reject the unknown flag at parse time —
|
||||
// no stub is registered because the command should never reach the API.
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// readClipboardImageBytes reads the current clipboard image and returns the
|
||||
@@ -35,13 +37,13 @@ func readClipboardImageBytes() ([]byte, error) {
|
||||
case "linux":
|
||||
data, err = readClipboardLinux()
|
||||
default:
|
||||
return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("clipboard contains no image data")
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -91,9 +93,9 @@ func readClipboardDarwin() ([]byte, error) {
|
||||
}
|
||||
|
||||
if stderrText != "" {
|
||||
return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText)
|
||||
}
|
||||
return nil, fmt.Errorf("clipboard contains no image data")
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
|
||||
}
|
||||
|
||||
// runOsascript invokes osascript with a single AppleScript expression and
|
||||
@@ -188,14 +190,14 @@ func decodeOsascriptData(s string) ([]byte, error) {
|
||||
// decodeHex decodes an uppercase hex string (as produced by osascript) to bytes.
|
||||
func decodeHex(h string) ([]byte, error) {
|
||||
if len(h)%2 != 0 {
|
||||
return nil, fmt.Errorf("odd hex length")
|
||||
return nil, fmt.Errorf("odd hex length") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
|
||||
}
|
||||
b := make([]byte, len(h)/2)
|
||||
for i := 0; i < len(h); i += 2 {
|
||||
hi := hexVal(h[i])
|
||||
lo := hexVal(h[i+1])
|
||||
if hi < 0 || lo < 0 {
|
||||
return nil, fmt.Errorf("invalid hex char at %d", i)
|
||||
return nil, fmt.Errorf("invalid hex char at %d", i) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
|
||||
}
|
||||
b[i/2] = byte(hi<<4 | lo)
|
||||
}
|
||||
@@ -237,12 +239,12 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("clipboard read failed (%s)", msg)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg)
|
||||
}
|
||||
b64 := strings.TrimSpace(string(out))
|
||||
data, decErr := base64.StdEncoding.DecodeString(b64)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("clipboard image decode failed: %w", decErr)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -325,15 +327,15 @@ func readClipboardLinux() ([]byte, error) {
|
||||
foundTool = true
|
||||
out, err := exec.Command(t.name, t.args...).Output()
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err)
|
||||
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err)
|
||||
continue
|
||||
}
|
||||
if len(out) == 0 {
|
||||
lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name)
|
||||
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name)
|
||||
continue
|
||||
}
|
||||
if t.validatePNG && !hasPNGMagic(out) {
|
||||
lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name)
|
||||
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name)
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
@@ -342,8 +344,8 @@ func readClipboardLinux() ([]byte, error) {
|
||||
if foundTool && lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"clipboard image read failed: no supported tool found. " +
|
||||
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager " +
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"clipboard image read failed: no supported tool found. "+
|
||||
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager "+
|
||||
"(apt, dnf, pacman, apk, brew, etc.).")
|
||||
}
|
||||
|
||||
16
shortcuts/doc/doc_errors.go
Normal file
16
shortcuts/doc/doc_errors.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wrapDocNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving its subtype / code / log_id from the runtime boundary),
|
||||
// and only wraps a raw, unclassified error as a transport-level network error.
|
||||
func wrapDocNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -51,10 +51,10 @@ var DocMediaDownload = common.Shortcut{
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token))
|
||||
@@ -73,7 +73,7 @@ var DocMediaDownload = common.Shortcut{
|
||||
ApiPath: apiPath,
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %v", err)
|
||||
return wrapDocNetworkErr(err, "download failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -86,14 +86,14 @@ var DocMediaDownload = common.Shortcut{
|
||||
// Validate final path after extension append
|
||||
if finalPath != outputPath {
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Overwrite check on final path (after extension detection)
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ var DocMediaDownload = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -67,10 +67,10 @@ var DocMediaInsert = common.Shortcut{
|
||||
filePath := runtime.Str("file")
|
||||
fromClipboard := runtime.Bool("from-clipboard")
|
||||
if filePath == "" && !fromClipboard {
|
||||
return common.FlagErrorf("one of --file or --from-clipboard is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required")
|
||||
}
|
||||
if filePath != "" && fromClipboard {
|
||||
return common.FlagErrorf("--file and --from-clipboard are mutually exclusive")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive")
|
||||
}
|
||||
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -78,7 +78,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if docRef.Kind == "doc" {
|
||||
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
}
|
||||
rawSelection := runtime.Str("selection-with-ellipsis")
|
||||
trimmedSelection := strings.TrimSpace(rawSelection)
|
||||
@@ -87,36 +87,36 @@ var DocMediaInsert = common.Shortcut{
|
||||
// trim-to-empty would make +media-insert fall back to append-mode and
|
||||
// write at the wrong location.
|
||||
if rawSelection != "" && trimmedSelection == "" {
|
||||
return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis must not be blank or whitespace-only").WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
if runtime.Bool("before") && trimmedSelection == "" {
|
||||
return output.ErrValidation("--before requires --selection-with-ellipsis")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--before requires --selection-with-ellipsis").WithParam("--before")
|
||||
}
|
||||
if view := runtime.Str("file-view"); view != "" {
|
||||
if _, ok := fileViewMap[view]; !ok {
|
||||
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-view value %q, expected one of: card | preview | inline", view).WithParam("--file-view")
|
||||
}
|
||||
if runtime.Str("type") != "file" {
|
||||
return output.ErrValidation("--file-view only applies when --type=file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-view only applies when --type=file").WithParam("--file-view")
|
||||
}
|
||||
}
|
||||
widthChanged := runtime.Changed("width")
|
||||
heightChanged := runtime.Changed("height")
|
||||
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
|
||||
return output.ErrValidation("--width/--height only apply when --type=image")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image")
|
||||
}
|
||||
if widthChanged && runtime.Int("width") <= 0 {
|
||||
return output.ErrValidation("--width must be a positive integer")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width")
|
||||
}
|
||||
if heightChanged && runtime.Int("height") <= 0 {
|
||||
return output.ErrValidation("--height must be a positive integer")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be a positive integer").WithParam("--height")
|
||||
}
|
||||
const maxDimension = 10000
|
||||
if widthChanged && runtime.Int("width") > maxDimension {
|
||||
return output.ErrValidation("--width must not exceed %d pixels", maxDimension)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must not exceed %d pixels", maxDimension).WithParam("--width")
|
||||
}
|
||||
if heightChanged && runtime.Int("height") > maxDimension {
|
||||
return output.ErrValidation("--height must not exceed %d pixels", maxDimension)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must not exceed %d pixels", maxDimension).WithParam("--height")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -269,10 +269,10 @@ var DocMediaInsert = common.Shortcut{
|
||||
} else {
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
return common.WrapInputStatErrorTyped(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
fileSize = stat.Size()
|
||||
fileName = filepath.Base(filePath)
|
||||
@@ -284,7 +284,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
}
|
||||
|
||||
// Step 1: Get document root block to find where to insert
|
||||
rootData, err := runtime.CallAPI("GET",
|
||||
rootData, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
@@ -318,7 +318,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
// Step 2: Create an empty block at the target position
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex)
|
||||
|
||||
createData, err := runtime.CallAPI("POST",
|
||||
createData, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
|
||||
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
|
||||
if err != nil {
|
||||
@@ -328,7 +328,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType)
|
||||
|
||||
if blockId == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create block: no block_id returned")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId)
|
||||
@@ -340,7 +340,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
// later steps should try to remove it instead of leaving an empty artifact.
|
||||
rollback := func() error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId)
|
||||
_, err := runtime.CallAPI("DELETE",
|
||||
_, err := runtime.CallAPITyped("DELETE",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
|
||||
nil, buildDeleteBlockData(insertIndex))
|
||||
return err
|
||||
@@ -379,14 +379,14 @@ var DocMediaInsert = common.Shortcut{
|
||||
} else {
|
||||
f, openErr := runtime.FileIO().Open(filePath)
|
||||
if openErr != nil {
|
||||
return withRollbackWarning(output.ErrValidation(
|
||||
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
|
||||
}
|
||||
nativeW, nativeH, dimErr = detectImageDimensions(f)
|
||||
f.Close()
|
||||
}
|
||||
if dimErr != nil {
|
||||
return withRollbackWarning(output.ErrValidation(
|
||||
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
|
||||
}
|
||||
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
|
||||
@@ -417,7 +417,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
// Step 4: Bind file token to block via batch_update
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID)
|
||||
|
||||
if _, err := runtime.CallAPI("PATCH",
|
||||
if _, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
|
||||
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil {
|
||||
return withRollbackWarning(err)
|
||||
@@ -512,10 +512,10 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
|
||||
case "docx":
|
||||
return docRef.Token, nil
|
||||
case "doc":
|
||||
return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
case "wiki":
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": docRef.Token},
|
||||
@@ -529,16 +529,16 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType != "docx" {
|
||||
return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
|
||||
return objToken, nil
|
||||
default:
|
||||
return "", output.ErrValidation("docs +media-insert only supports docx documents")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +622,7 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin
|
||||
func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (parentBlockID string, insertIndex int, children []interface{}, err error) {
|
||||
block, _ := rootData["block"].(map[string]interface{})
|
||||
if len(block) == 0 {
|
||||
return "", 0, nil, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block")
|
||||
return "", 0, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to query document root block")
|
||||
}
|
||||
|
||||
parentBlockID = fallbackBlockID
|
||||
@@ -653,12 +653,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
|
||||
|
||||
matches := common.GetSlice(result, "matches")
|
||||
if len(matches) == 0 {
|
||||
return 0, output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"no_match",
|
||||
fmt.Sprintf("locate-doc did not find any block matching selection (%s)", redactSelection(selection)),
|
||||
"check spelling or use 'start...end' syntax to narrow the selection",
|
||||
)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"locate-doc did not find any block matching selection (%s)", redactSelection(selection)).
|
||||
WithParam("--selection-with-ellipsis").
|
||||
WithHint("check spelling or use 'start...end' syntax to narrow the selection")
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
// Silently picking the first match surprises users whose selection appears
|
||||
@@ -682,7 +680,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
|
||||
}
|
||||
}
|
||||
if anchorBlockID == "" {
|
||||
return 0, output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
|
||||
return 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
|
||||
}
|
||||
parentBlockID := common.GetString(matchMap, "parent_block_id")
|
||||
|
||||
@@ -740,7 +738,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
|
||||
nextParent = "" // clear hint after first use
|
||||
if parent == "" || parent == cur {
|
||||
// Need to fetch this block to find its parent.
|
||||
data, err := runtime.CallAPI("GET",
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s",
|
||||
validate.EncodePathSegment(documentID), validate.EncodePathSegment(cur)),
|
||||
nil, nil)
|
||||
@@ -757,12 +755,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
|
||||
walkDepth++
|
||||
}
|
||||
|
||||
return 0, output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"block_not_reachable",
|
||||
fmt.Sprintf("block matching selection (%s) is not reachable from document root", redactSelection(selection)),
|
||||
"try a top-level heading or paragraph as the selection",
|
||||
)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"block matching selection (%s) is not reachable from document root", redactSelection(selection)).
|
||||
WithParam("--selection-with-ellipsis").
|
||||
WithHint("try a top-level heading or paragraph as the selection")
|
||||
}
|
||||
|
||||
func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -45,11 +45,11 @@ var DocMediaPreview = common.Shortcut{
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
if err := validate.ResourceName(token, "--token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
// Early path validation before API call (final validation after auto-extension below)
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token))
|
||||
@@ -65,7 +65,7 @@ var DocMediaPreview = common.Shortcut{
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("preview failed: %v", err)
|
||||
return wrapDocNetworkErr(err, "preview failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -74,14 +74,14 @@ var DocMediaPreview = common.Shortcut{
|
||||
// Validate final path after extension append
|
||||
if finalPath != outputPath {
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Overwrite check on final path (after extension detection)
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ var DocMediaPreview = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -84,10 +84,10 @@ var DocMediaUpload = common.Shortcut{
|
||||
// Validate file
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
return common.WrapInputStatErrorTyped(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -25,10 +26,10 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("content") == "" {
|
||||
return common.FlagErrorf("--content is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return common.FlagErrorf("invalid --doc: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
|
||||
}
|
||||
if err := validateFetchDetail(runtime); err != nil {
|
||||
return err
|
||||
@@ -153,7 +154,7 @@ func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
if detail == "with-ids" || detail == "full" {
|
||||
return common.FlagErrorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -166,13 +167,13 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
if v := runtime.Int("context-before"); v < 0 {
|
||||
return common.FlagErrorf("--context-before must be >= 0, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-before must be >= 0, got %d", v).WithParam("--context-before")
|
||||
}
|
||||
if v := runtime.Int("context-after"); v < 0 {
|
||||
return common.FlagErrorf("--context-after must be >= 0, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-after must be >= 0, got %d", v).WithParam("--context-after")
|
||||
}
|
||||
if v := runtime.Int("max-depth"); v < -1 {
|
||||
return common.FlagErrorf("--max-depth must be >= -1, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-depth must be >= -1, got %d", v).WithParam("--max-depth")
|
||||
}
|
||||
|
||||
switch mode {
|
||||
@@ -181,20 +182,20 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
case "range":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||||
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
|
||||
return common.FlagErrorf("range mode requires --start-block-id or --end-block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id")
|
||||
}
|
||||
return nil
|
||||
case "keyword":
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return common.FlagErrorf("keyword mode requires --keyword")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keyword mode requires --keyword").WithParam("--keyword")
|
||||
}
|
||||
return nil
|
||||
case "section":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
|
||||
return common.FlagErrorf("section mode requires --start-block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "section mode requires --start-block-id").WithParam("--start-block-id")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return common.FlagErrorf("invalid --scope %q", mode)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -58,7 +59,7 @@ var DocsSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,7 +160,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
|
||||
|
||||
var filter map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
|
||||
return nil, output.ErrValidation("--filter is not valid JSON")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter")
|
||||
}
|
||||
if err := convertTimeRangeInFilter(filter, "open_time"); err != nil {
|
||||
return nil, err
|
||||
@@ -172,7 +173,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
|
||||
hasSpaceIDs := hasNonEmptyFilterArray(filter, "space_ids")
|
||||
|
||||
if hasFolderTokens && hasSpaceIDs {
|
||||
return nil, output.ErrValidation("--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined").WithParam("--filter")
|
||||
}
|
||||
|
||||
docFilter := cloneFilterMap(filter)
|
||||
@@ -225,14 +226,14 @@ func convertTimeRangeInFilter(filter map[string]interface{}, key string) error {
|
||||
if start, ok := rangeMap["start"].(string); ok && start != "" {
|
||||
startTime, err := toUnixSeconds(start)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid %s.start %q: %s", key, start, err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter")
|
||||
}
|
||||
result["start"] = startTime
|
||||
}
|
||||
if end, ok := rangeMap["end"].(string); ok && end != "" {
|
||||
endTime, err := toUnixSeconds(end)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid %s.end %q: %s", key, end, err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter")
|
||||
}
|
||||
result["end"] = endTime
|
||||
}
|
||||
@@ -256,7 +257,7 @@ func toUnixSeconds(input string) (int64, error) {
|
||||
if n, err := strconv.ParseInt(input, 10, 64); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds")
|
||||
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
|
||||
}
|
||||
|
||||
func unixTimestampToISO8601(v interface{}) string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -43,14 +44,14 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||
return common.FlagErrorf("invalid --doc: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
|
||||
}
|
||||
cmd := runtime.Str("command")
|
||||
if cmd == "" {
|
||||
return common.FlagErrorf("--command is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command is required").WithParam("--command")
|
||||
}
|
||||
if !validCommandsV2[cmd] {
|
||||
return common.FlagErrorf("invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
|
||||
}
|
||||
content := runtime.Str("content")
|
||||
pattern := runtime.Str("pattern")
|
||||
@@ -60,50 +61,50 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
switch cmd {
|
||||
case "str_replace":
|
||||
if pattern == "" {
|
||||
return common.FlagErrorf("--command str_replace requires --pattern")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_delete requires --block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_insert_after requires --block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command block_insert_after requires --content")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --content").WithParam("--content")
|
||||
}
|
||||
case "block_copy_insert_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_copy_insert_after requires --block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if srcBlockIDs == "" {
|
||||
return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --src-block-ids").WithParam("--src-block-ids")
|
||||
}
|
||||
case "block_move_after":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_move_after requires --block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if srcBlockIDs == "" {
|
||||
return common.FlagErrorf("--command block_move_after requires --src-block-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --src-block-ids").WithParam("--src-block-ids")
|
||||
}
|
||||
if content != "" {
|
||||
return common.FlagErrorf("--command block_move_after does not accept --content; use --src-block-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after does not accept --content; use --src-block-ids").WithParam("--content")
|
||||
}
|
||||
case "block_replace":
|
||||
if blockID == "" {
|
||||
return common.FlagErrorf("--command block_replace requires --block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command block_replace requires --content")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --content").WithParam("--content")
|
||||
}
|
||||
case "overwrite":
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command overwrite requires --content")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command overwrite requires --content").WithParam("--content")
|
||||
}
|
||||
case "append":
|
||||
if content == "" {
|
||||
return common.FlagErrorf("--command append requires --content")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ type documentRef struct {
|
||||
func parseDocumentRef(input string) (documentRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return documentRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
|
||||
}
|
||||
|
||||
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
|
||||
@@ -37,10 +37,10 @@ func parseDocumentRef(input string) (documentRef, error) {
|
||||
return documentRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw)
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw)
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx token or a wiki URL", raw).WithParam("--doc")
|
||||
}
|
||||
|
||||
return documentRef{Kind: "docx", Token: raw}, nil
|
||||
@@ -64,10 +64,10 @@ func extractDocumentToken(raw, marker string) (string, bool) {
|
||||
|
||||
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
|
||||
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
|
||||
// Uses the log-id-aware variant so the x-tt-logid header is surfaced in both the
|
||||
// success payload and error details — doc v2 callers rely on it for support escalations.
|
||||
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
|
||||
// surfaces for support escalations even when the body omits it.
|
||||
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
|
||||
return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body)
|
||||
return runtime.CallAPITyped(method, apiPath, nil, body)
|
||||
}
|
||||
|
||||
func docsSceneFromContext(ctx context.Context) string {
|
||||
@@ -87,7 +87,7 @@ func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{}
|
||||
func buildDriveRouteExtra(docID string) (string, error) {
|
||||
extra, err := json.Marshal(map[string]string{"drive_route_token": docID})
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal_error", "failed to marshal upload extra data: %v", err)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to marshal upload extra data: %v", err).WithCause(err)
|
||||
}
|
||||
return string(extra), nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package doc
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -65,7 +66,7 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF
|
||||
switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
|
||||
case "", "v1", "v2":
|
||||
default:
|
||||
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API")
|
||||
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API", "--api-version")
|
||||
}
|
||||
|
||||
var used []string
|
||||
@@ -87,11 +88,12 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF
|
||||
if len(replacements) > 0 {
|
||||
detail += "; " + strings.Join(replacements, "; ")
|
||||
}
|
||||
return docsV2OnlyError(shortcut, detail)
|
||||
return docsV2OnlyError(shortcut, detail, used[0])
|
||||
}
|
||||
|
||||
func docsV2OnlyError(shortcut, detail string) error {
|
||||
return common.FlagErrorf(
|
||||
func docsV2OnlyError(shortcut, detail, param string) error {
|
||||
err := errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags",
|
||||
shortcut,
|
||||
detail,
|
||||
@@ -100,4 +102,8 @@ func docsV2OnlyError(shortcut, detail string) error {
|
||||
docsMDSkillReadCommand,
|
||||
docsHelpCommandForShortcut(shortcut),
|
||||
)
|
||||
if param != "" {
|
||||
err = err.WithParam(param)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@ package markdown
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"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/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -28,8 +28,17 @@ const markdownEmptyContentError = "empty markdown content is not supported; cann
|
||||
const (
|
||||
markdownUploadParentTypeExplorer = "explorer"
|
||||
markdownUploadParentTypeWiki = "wiki"
|
||||
markdownUploadAllAction = "upload markdown file failed"
|
||||
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
|
||||
markdownUploadFinishAction = "finalize markdown multipart upload failed"
|
||||
markdownFetchNameAction = "fetch existing markdown file name failed"
|
||||
)
|
||||
|
||||
var markdownUploadRetryBackoffs = []time.Duration{
|
||||
200 * time.Millisecond,
|
||||
500 * time.Millisecond,
|
||||
}
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
@@ -74,16 +83,18 @@ func (spec markdownUploadSpec) Target() markdownUploadTarget {
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
return common.FlagErrorf("--content and --file are mutually exclusive")
|
||||
return markdownValidationError("--content and --file are mutually exclusive").
|
||||
WithParams(markdownInvalidParam("--content", "mutually exclusive"), markdownInvalidParam("--file", "mutually exclusive"))
|
||||
case !spec.ContentSet && !spec.FileSet:
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
return markdownValidationError("specify exactly one of --content or --file").
|
||||
WithParams(markdownInvalidParam("--content", "required; specify exactly one"), markdownInvalidParam("--file", "required; specify exactly one"))
|
||||
}
|
||||
|
||||
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
return markdownValidationParamError("--folder-token", "--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
|
||||
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
return markdownValidationParamError("--wiki-token", "--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
}
|
||||
targets := 0
|
||||
if spec.FolderToken != "" {
|
||||
@@ -93,22 +104,23 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
targets++
|
||||
}
|
||||
if targets > 1 {
|
||||
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
|
||||
return markdownValidationError("--folder-token and --wiki-token are mutually exclusive").
|
||||
WithParams(markdownInvalidParam("--folder-token", "mutually exclusive"), markdownInvalidParam("--wiki-token", "mutually exclusive"))
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return markdownValidationParamError("--folder-token", "%s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return markdownValidationParamError("--wiki-token", "%s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
return common.FlagErrorf("--name is required when using --content")
|
||||
return markdownValidationParamError("--name", "--name is required when using --content")
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
@@ -117,10 +129,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
|
||||
if spec.FileSet {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return common.FlagErrorf("--file cannot be empty")
|
||||
return markdownValidationParamError("--file", "--file cannot be empty")
|
||||
}
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
|
||||
}
|
||||
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
|
||||
return err
|
||||
@@ -143,10 +155,10 @@ func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return common.FlagErrorf("%s cannot be empty", flagName)
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
|
||||
return common.FlagErrorf("%s must end with .md", flagName)
|
||||
return markdownValidationParamError(flagName, "%s must end with .md", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -190,22 +202,9 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
// Preserve any already-classified error: legacy *output.ExitError or any
|
||||
// typed errs.* error. Only un-classified errors get wrapped as network.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
|
||||
func validateNonEmptyMarkdownSize(size int64) error {
|
||||
if size == 0 {
|
||||
return output.ErrValidation("%s", markdownEmptyContentError)
|
||||
return markdownValidationError("%s", markdownEmptyContentError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -216,12 +215,12 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
size = int64(len(spec.Content))
|
||||
} else {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return 0, common.FlagErrorf("--file cannot be empty")
|
||||
return 0, markdownValidationParamError("--file", "--file cannot be empty")
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, common.WrapInputStatError(err)
|
||||
return 0, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
@@ -387,58 +386,68 @@ func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSp
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
fileSize := int64(len(payload))
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
})
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
f, err := runtime.FileIO().Open(spec.FilePath)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
|
||||
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return runtime.FileIO().Open(spec.FilePath)
|
||||
})
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
|
||||
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return runtime.FileIO().Open(spec.FilePath)
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return markdownUploadResult{}, err
|
||||
return withMarkdownUploadRetryResult(runtime, markdownUploadAllAction, func() (markdownUploadResult, error) {
|
||||
fileReader, err := openReader()
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(client.WrapDoAPIError(err), markdownUploadAllAction)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
|
||||
}
|
||||
result, err := parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
@@ -450,31 +459,53 @@ func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUp
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
prepareResult, err := withMarkdownUploadRetryData(runtime, markdownUploadPrepareAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownUploadPrepareAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
session, err := parseMarkdownMultipartSession(prepareResult)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadPrepareAction)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
|
||||
|
||||
fileReader, err := openReader()
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
finishResult, err := withMarkdownUploadRetryData(runtime, markdownUploadFinishAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownUploadFinishAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
result, err := parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadFinishAction)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
|
||||
@@ -484,7 +515,7 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
|
||||
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
return markdownMultipartSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
session.UploadID, session.BlockSize, session.BlockNum)
|
||||
}
|
||||
@@ -494,9 +525,8 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
|
||||
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
|
||||
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
|
||||
if session.BlockNum != expectedBlocks {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
|
||||
session.BlockSize,
|
||||
session.BlockNum,
|
||||
@@ -507,7 +537,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if session.BlockSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
|
||||
buffer := make([]byte, int(session.BlockSize))
|
||||
@@ -521,29 +551,34 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
|
||||
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return output.ErrValidation("cannot read file: %s", readErr)
|
||||
return markdownValidationError("cannot read file: %s", readErr).WithCause(readErr)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
action := fmt.Sprintf("upload markdown file part %d/%d failed", seq+1, session.BlockNum)
|
||||
if err := withMarkdownUploadRetryVoid(runtime, action, func() error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return markdownUploadProblem(client.WrapDoAPIError(err), action)
|
||||
}
|
||||
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
|
||||
}
|
||||
|
||||
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return markdownUploadProblem(err, action)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -551,9 +586,8 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
|
||||
remaining,
|
||||
session.BlockNum,
|
||||
@@ -572,28 +606,34 @@ func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool)
|
||||
result.Version = common.GetString(data, "data_version")
|
||||
}
|
||||
if result.FileToken == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
}
|
||||
if requireVersion && result.Version == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
|
||||
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite failed: no version returned")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
data, err := withMarkdownUploadRetryData(runtime, markdownFetchNameAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownFetchNameAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -606,6 +646,97 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryResult(runtime *common.RuntimeContext, action string, fn func() (markdownUploadResult, error)) (markdownUploadResult, error) {
|
||||
var zero markdownUploadResult
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return zero, markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryData(runtime *common.RuntimeContext, action string, fn func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return nil, markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryVoid(runtime *common.RuntimeContext, action string, fn func() error) error {
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func markdownUploadShouldRetry(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p == nil {
|
||||
return false
|
||||
}
|
||||
return p.Retryable || p.Category == errs.CategoryNetwork
|
||||
}
|
||||
|
||||
func markdownUploadRetryExhausted(err error, action string, retries int) error {
|
||||
if retries <= 0 {
|
||||
return err
|
||||
}
|
||||
return appendMarkdownProblemHint(err, fmt.Sprintf("%s remained retryable after %d attempts; retry later if the upstream service is throttling or temporarily unavailable", action, retries+1))
|
||||
}
|
||||
|
||||
func markdownUploadProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = action + ": " + p.Message
|
||||
switch p.Code {
|
||||
case 99991672, 99991679:
|
||||
appendMarkdownProblemHint(err, "The current token or identity lacks the required document upload scope/capability. Grant the document upload scope or use a token with the appropriate permissions, then retry.")
|
||||
case 10071:
|
||||
appendMarkdownProblemHint(err, "The target document has reached its version limit. Clean up old versions or create a new file before retrying.")
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func appendMarkdownProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
|
||||
@@ -5,7 +5,6 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -65,7 +65,7 @@ type markdownDiffHunkRange struct {
|
||||
|
||||
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
}
|
||||
if spec.FromVersion != "" {
|
||||
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
|
||||
@@ -79,29 +79,29 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
|
||||
}
|
||||
if spec.FilePath != "" {
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ContextLines < 0 {
|
||||
return output.ErrValidation("--context-lines must be >= 0")
|
||||
return markdownValidationParamError("--context-lines", "--context-lines must be >= 0")
|
||||
}
|
||||
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
|
||||
return output.ErrValidation("markdown +diff only supports --format json or pretty")
|
||||
return markdownValidationParamError("--format", "markdown +diff only supports --format json or pretty")
|
||||
}
|
||||
if spec.FilePath == "" {
|
||||
if spec.FromVersion == "" && spec.ToVersion == "" {
|
||||
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
return markdownValidationError("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
}
|
||||
if spec.FromVersion == "" && spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version requires --from-version")
|
||||
return markdownValidationParamError("--to-version", "--to-version requires --from-version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
return common.FlagErrorf("--to-version is not supported together with --file")
|
||||
return markdownValidationParamError("--to-version", "--to-version is not supported together with --file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -109,10 +109,10 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return output.ErrValidation("%s cannot be empty", flagName)
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return output.ErrValidation("%s must be a numeric version string", flagName)
|
||||
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -178,17 +178,16 @@ func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext
|
||||
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return "", common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
return "", markdownValidationError("cannot read file: %s", err).WithCause(err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
@@ -199,7 +198,7 @@ func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) > markdownDiffMaxContentBytes {
|
||||
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
return nil, markdownValidationError("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -214,18 +215,18 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
|
||||
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
|
||||
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(99991663)
|
||||
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
|
||||
}
|
||||
|
||||
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want typed problem", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
if problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
|
||||
t.Fatalf("wrapped error = %q", got.Error())
|
||||
|
||||
49
shortcuts/markdown/markdown_errors.go
Normal file
49
shortcuts/markdown/markdown_errors.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func markdownValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func markdownValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return markdownValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func markdownInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
|
||||
func markdownNetworkError(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Category == errs.CategoryValidation {
|
||||
return err
|
||||
}
|
||||
return markdownPrefixProblem(err, "download failed")
|
||||
}
|
||||
return markdownNetworkError(err, "download failed: %s", err)
|
||||
}
|
||||
|
||||
func markdownPrefixProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(action) != "" {
|
||||
p.Message = action + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -35,14 +34,14 @@ var MarkdownFetch = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return markdownValidationParamError("--output", "unsafe output path: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -67,7 +66,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
return wrapMarkdownDownloadError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -75,7 +74,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
if outputPath == "" {
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
return wrapMarkdownDownloadError(err)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
@@ -93,7 +92,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
outputPath = filepath.Join(outputPath, fileName)
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
return markdownValidationParamError("--output", "output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
@@ -101,7 +100,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -30,7 +29,7 @@ var MarkdownOverwrite = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
}
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -49,7 +48,7 @@ var MarkdownPatch = common.Shortcut{
|
||||
}
|
||||
if spec.Regex {
|
||||
if _, err := regexp.Compile(spec.Pattern); err != nil {
|
||||
return output.ErrValidation("invalid --pattern regex: %s", err)
|
||||
return markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -122,7 +121,7 @@ var MarkdownPatch = common.Shortcut{
|
||||
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
return wrapMarkdownDownloadError(err)
|
||||
}
|
||||
original := string(payload)
|
||||
patched, matchCount, err := applyMarkdownPatch(original, spec)
|
||||
@@ -192,16 +191,16 @@ func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
|
||||
|
||||
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
}
|
||||
if !runtime.Changed("pattern") {
|
||||
return common.FlagErrorf("--pattern is required")
|
||||
return markdownValidationParamError("--pattern", "--pattern is required")
|
||||
}
|
||||
if spec.Pattern == "" {
|
||||
return output.ErrValidation("--pattern cannot be empty")
|
||||
return markdownValidationParamError("--pattern", "--pattern cannot be empty")
|
||||
}
|
||||
if !spec.ContentSet {
|
||||
return common.FlagErrorf("--content is required")
|
||||
return markdownValidationParamError("--content", "--content is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -212,7 +211,7 @@ func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, e
|
||||
}
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
|
||||
return "", 0, markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
|
||||
}
|
||||
matches := re.FindAllStringIndex(original, -1)
|
||||
return re.ReplaceAllString(original, spec.Content), len(matches), nil
|
||||
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -603,6 +605,100 @@ func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateUploadAllReturnsTypedScopeError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "Access denied. One of the following scopes is required: [drive:file:upload]",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-upload-scope",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error")
|
||||
}
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 99991672 {
|
||||
t.Fatalf("code = %d, want 99991672", p.Code)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Fatalf("subtype = %s, want %s", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "lacks the required document upload scope") {
|
||||
t.Fatalf("hint = %q, want upload scope guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateUploadAllRetriesRateLimit(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "request frequency limit exceeded",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-upload-ratelimit-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_retry_success",
|
||||
"version": "1003",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_retry_success", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_retry_success"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "retrying (attempt 1/2)") {
|
||||
t.Fatalf("stderr = %q, want retry log", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_token": "box_md_retry_success"`) {
|
||||
t.Fatalf("stdout missing retried upload token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1033,6 +1129,270 @@ func TestUploadMarkdownMultipartPartsRejectsOversizedBlockSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMarkdownUploadRetryDataDoesNotRetryNonRetryable(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
|
||||
|
||||
attempts := 0
|
||||
expected := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(1061004)
|
||||
_, err := withMarkdownUploadRetryData(rt, markdownUploadAllAction, func() (map[string]interface{}, error) {
|
||||
attempts++
|
||||
return nil, expected
|
||||
})
|
||||
if err != expected {
|
||||
t.Fatalf("err = %v, want original error", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
t.Fatalf("stderr = %q, want no retry log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMarkdownUploadRetryVoidExhaustedAppendsHint(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
|
||||
|
||||
orig := markdownUploadRetryBackoffs
|
||||
markdownUploadRetryBackoffs = []time.Duration{0, 0}
|
||||
t.Cleanup(func() { markdownUploadRetryBackoffs = orig })
|
||||
|
||||
attempts := 0
|
||||
err := withMarkdownUploadRetryVoid(rt, markdownUploadFinishAction, func() error {
|
||||
attempts++
|
||||
return errs.NewAPIError(errs.SubtypeRateLimit, "too many requests").WithCode(99991400).WithRetryable()
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "remained retryable after 3 attempts") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion guidance", p.Hint)
|
||||
}
|
||||
if strings.Count(stderr.String(), "retrying (attempt") != 2 {
|
||||
t.Fatalf("stderr = %q, want 2 retry logs", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadShouldRetryBranches(t *testing.T) {
|
||||
if markdownUploadShouldRetry(errors.New("plain")) {
|
||||
t.Fatal("plain error should not be retryable")
|
||||
}
|
||||
if !markdownUploadShouldRetry(errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()) {
|
||||
t.Fatal("retryable API error should be retryable")
|
||||
}
|
||||
if !markdownUploadShouldRetry(errs.NewNetworkError(errs.SubtypeNetworkServer, "gateway").WithCode(502)) {
|
||||
t.Fatal("network error should be retryable by category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadRetryExhaustedZeroRetriesKeepsOriginal(t *testing.T) {
|
||||
original := errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()
|
||||
got := markdownUploadRetryExhausted(original, markdownUploadAllAction, 0)
|
||||
if got != original {
|
||||
t.Fatalf("got = %v, want original error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAppendsCodeSpecificHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing scope",
|
||||
code: 99991672,
|
||||
want: "lacks the required document upload scope",
|
||||
},
|
||||
{
|
||||
name: "version limit",
|
||||
code: 10071,
|
||||
want: "reached its version limit",
|
||||
},
|
||||
{
|
||||
name: "document capability",
|
||||
code: 90003087,
|
||||
want: "document capabilities enabled",
|
||||
},
|
||||
{
|
||||
name: "target not found",
|
||||
code: 1061044,
|
||||
want: "target folder or wiki node still exists",
|
||||
},
|
||||
{
|
||||
name: "no write access",
|
||||
code: 1062501,
|
||||
want: "has write access",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(tt.code)
|
||||
got := markdownUploadProblem(err, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", got, got)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want action prefix", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, tt.want) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMarkdownFileAllMissingFileTokenGetsActionPrefix(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileAll(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMarkdownFileMultipartPrepareAndFinishParseErrorsGetActionPrefix(t *testing.T) {
|
||||
t.Run("prepare", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileMultipart(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected prepare parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadPrepareAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadPrepareAction+": ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finish", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(8),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileMultipart(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected finish parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadFinishAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadFinishAction+": ")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendMarkdownProblemHintAppendsAndIgnoresBlank(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
|
||||
appendMarkdownProblemHint(err, "second")
|
||||
appendMarkdownProblemHint(err, " ")
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Hint != "first\nsecond" {
|
||||
t.Fatalf("hint = %q, want newline-joined hints", p.Hint)
|
||||
}
|
||||
|
||||
plain := errors.New("plain")
|
||||
if got := appendMarkdownProblemHint(plain, "ignored"); got != plain {
|
||||
t.Fatalf("plain error should pass through unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteUploadAllIncludesFileTokenAndVersion(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1303,7 +1663,18 @@ func TestMarkdownOverwriteRejectsEmptyLocalFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-meta-notfound",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{
|
||||
"+overwrite",
|
||||
@@ -1313,6 +1684,19 @@ func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected metadata lookup failure")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 1061044 {
|
||||
t.Fatalf("code = %d, want 1061044", p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownFetchNameAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownFetchNameAction+": ")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "target folder or wiki node still exists") {
|
||||
t.Fatalf("hint = %q, want target guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteMissingFileReturnsReadError(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -27,7 +27,7 @@ var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "
|
||||
|
||||
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
|
||||
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (s
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, "no sheets found in this spreadsheet")
|
||||
}
|
||||
|
||||
// extractSpreadsheetToken extracts spreadsheet token from URL.
|
||||
@@ -104,7 +104,7 @@ func validateSheetRangeInput(sheetID, input string) error {
|
||||
return nil
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
|
||||
return common.ValidationErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func validateSingleCellRange(input string) error {
|
||||
if strings.EqualFold(parts[0], parts[1]) {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
|
||||
return common.ValidationErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -197,11 +197,11 @@ func matrixDimensions(values interface{}) (rows, cols int) {
|
||||
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
|
||||
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
|
||||
if len(matches) != 3 {
|
||||
return "", fmt.Errorf("invalid cell reference: %s", cell)
|
||||
return "", fmt.Errorf("invalid cell reference: %s", cell) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
|
||||
}
|
||||
colIndex := columnNameToIndex(matches[1])
|
||||
if colIndex < 1 {
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1])
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1]) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
|
||||
}
|
||||
rowIndex, err := strconv.Atoi(matches[2])
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -15,10 +16,10 @@ import (
|
||||
func parseValues2DJSON(raw string) ([][]interface{}, error) {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
|
||||
}
|
||||
if rows == nil {
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -46,7 +47,7 @@ var SheetRead = common.Shortcut{
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -90,7 +91,7 @@ var SheetRead = common.Shortcut{
|
||||
params["valueRenderOption"] = renderOption
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,7 +168,7 @@ var SheetWrite = common.Shortcut{
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
"range": writeRange,
|
||||
"values": values,
|
||||
@@ -247,7 +248,7 @@ var SheetAppend = common.Shortcut{
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
"range": appendRange,
|
||||
"values": values,
|
||||
@@ -288,7 +289,7 @@ var SheetFind = common.Shortcut{
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -336,7 +337,7 @@ var SheetFind = common.Shortcut{
|
||||
"find": findText,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
|
||||
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -373,7 +374,7 @@ var SheetReplace = common.Shortcut{
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -415,7 +416,7 @@ var SheetReplace = common.Shortcut{
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(sheetID),
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -38,7 +38,7 @@ var SheetWriteImage = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -91,7 +91,7 @@ var SheetWriteImage = common.Shortcut{
|
||||
|
||||
imageBytes, err := io.ReadAll(imageFile)
|
||||
if err != nil {
|
||||
return output.ErrValidation("cannot read image file: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
|
||||
}
|
||||
|
||||
imageName := runtime.Str("name")
|
||||
@@ -101,7 +101,7 @@ var SheetWriteImage = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"range": pointRange,
|
||||
"image": imageBytes,
|
||||
"name": imageName,
|
||||
@@ -116,35 +116,35 @@ var SheetWriteImage = common.Shortcut{
|
||||
|
||||
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
|
||||
if fio == nil {
|
||||
return nil, output.ErrValidation("no file I/O provider registered")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no file I/O provider registered")
|
||||
}
|
||||
stat, err := fio.Stat(imagePath)
|
||||
if err != nil {
|
||||
return nil, wrapSheetWriteImageStatError(err, imagePath)
|
||||
}
|
||||
if stat.IsDir() || !stat.Mode().IsRegular() {
|
||||
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image must be a regular file: %s", imagePath).WithParam("--image")
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024
|
||||
if stat.Size() > maxImageSize {
|
||||
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024).WithParam("--image")
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
func wrapSheetWriteImageStatError(err error, imagePath string) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return output.ErrValidation("image file not found: %s", imagePath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "image file not found: %s", imagePath).WithParam("--image").WithCause(err)
|
||||
}
|
||||
return output.ErrValidation("cannot stat image file: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot stat image file: %s", err).WithParam("--image").WithCause(err)
|
||||
}
|
||||
|
||||
func wrapSheetWriteImageOpenError(err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
|
||||
}
|
||||
return output.ErrValidation("cannot read image file: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -15,40 +16,40 @@ import (
|
||||
func validateBatchStyleData(raw string) error {
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return common.FlagErrorf("--data must be a non-empty JSON array")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a non-empty JSON array").WithParam("--data")
|
||||
}
|
||||
for i, item := range arr {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d] must be an object with ranges and style", i).WithParam("--data")
|
||||
}
|
||||
rangesRaw, ok := entry["ranges"]
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d].ranges is required", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges is required", i).WithParam("--data")
|
||||
}
|
||||
ranges, ok := rangesRaw.([]interface{})
|
||||
if !ok || len(ranges) == 0 {
|
||||
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges must be a non-empty array of strings", i).WithParam("--data")
|
||||
}
|
||||
for j, r := range ranges {
|
||||
s, ok := r.(string)
|
||||
if !ok || s == "" {
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] must be a non-empty string", i, j).WithParam("--data")
|
||||
}
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s).WithParam("--data")
|
||||
}
|
||||
}
|
||||
styleRaw, ok := entry["style"]
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d].style is required", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style is required", i).WithParam("--data")
|
||||
}
|
||||
if _, ok := styleRaw.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style must be a JSON object", i).WithParam("--data")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -74,14 +75,14 @@ var SheetSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
|
||||
}
|
||||
if _, ok := style.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--style must be a JSON object, got %T", style)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be a JSON object, got %T", style).WithParam("--style")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -115,10 +116,10 @@ var SheetSetStyle = common.Shortcut{
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
data, err := runtime.CallAPITyped("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -154,7 +155,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
return validateBatchStyleData(runtime.Str("data"))
|
||||
},
|
||||
@@ -181,11 +182,11 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
result, err := runtime.CallAPITyped("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -242,7 +243,7 @@ var SheetMergeCells = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -271,7 +272,7 @@ var SheetMergeCells = common.Shortcut{
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -306,7 +307,7 @@ var SheetUnmergeCells = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -334,7 +335,7 @@ var SheetUnmergeCells = common.Shortcut{
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -27,7 +28,7 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -35,10 +36,10 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
|
||||
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
|
||||
var typed []string
|
||||
if err := json.Unmarshal([]byte(value), &typed); err != nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array of strings: %v", flagName, err).WithParam("--" + flagName)
|
||||
}
|
||||
if typed == nil {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array, got null", flagName).WithParam("--" + flagName)
|
||||
}
|
||||
arr := make([]interface{}, len(typed))
|
||||
for i, s := range typed {
|
||||
@@ -53,12 +54,12 @@ func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return nil, common.FlagErrorf("--ranges must not be empty")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges must not be empty").WithParam("--ranges")
|
||||
}
|
||||
for i, r := range ranges {
|
||||
s, _ := r.(string)
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s).WithParam("--ranges")
|
||||
}
|
||||
}
|
||||
return ranges, nil
|
||||
@@ -70,7 +71,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
|
||||
return nil, err
|
||||
}
|
||||
if len(condValues) == 0 {
|
||||
return nil, common.FlagErrorf("--condition-values must not be empty")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--condition-values must not be empty").WithParam("--condition-values")
|
||||
}
|
||||
|
||||
dv := map[string]interface{}{
|
||||
@@ -90,7 +91,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) != len(condValues) {
|
||||
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)).WithParam("--colors")
|
||||
}
|
||||
opts["colors"] = colors
|
||||
}
|
||||
@@ -123,7 +124,7 @@ var SheetSetDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
@@ -147,7 +148,7 @@ var SheetSetDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
|
||||
data, err := runtime.CallAPITyped("POST", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
@@ -214,7 +215,7 @@ var SheetUpdateDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
data, err := runtime.CallAPITyped("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
@@ -247,7 +248,7 @@ var SheetGetDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -259,7 +260,7 @@ var SheetGetDropdown = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
|
||||
data, err := runtime.CallAPITyped("GET", dataValidationBasePath(token),
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
@@ -319,7 +320,7 @@ var SheetDeleteDropdown = common.Shortcut{
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
|
||||
data, err := runtime.CallAPITyped("DELETE", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,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"
|
||||
)
|
||||
@@ -59,7 +59,7 @@ var SheetCreateFilterView = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range must not be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must not be empty").WithParam("--range")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -85,7 +85,7 @@ var SheetCreateFilterView = common.Shortcut{
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
data, err := runtime.CallAPITyped("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -115,7 +115,7 @@ var SheetUpdateFilterView = common.Shortcut{
|
||||
}
|
||||
if !hasNonEmptyStringFlag(runtime, "range") &&
|
||||
!hasNonEmptyStringFlag(runtime, "filter-view-name") {
|
||||
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range or --filter-view-name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -141,7 +141,7 @@ var SheetUpdateFilterView = common.Shortcut{
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
data, err := runtime.CallAPITyped("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -174,7 +174,7 @@ var SheetListFilterViews = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,7 +208,7 @@ var SheetGetFilterView = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -242,7 +242,7 @@ var SheetDeleteFilterView = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
data, err := runtime.CallAPITyped("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -284,7 +284,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, true)
|
||||
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
data, err := runtime.CallAPITyped("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -317,7 +317,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
if !hasNonEmptyStringFlag(runtime, "filter-type") &&
|
||||
!hasNonEmptyStringFlag(runtime, "compare-type") &&
|
||||
!hasNonEmptyStringFlag(runtime, "expected") {
|
||||
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --filter-type, --compare-type, or --expected")
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
return validateExpectedFlag(s)
|
||||
@@ -335,7 +335,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := buildConditionBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
data, err := runtime.CallAPITyped("PUT",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
@@ -371,7 +371,7 @@ var SheetListFilterViewConditions = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET",
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
@@ -409,7 +409,7 @@ var SheetGetFilterViewCondition = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET",
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
@@ -447,7 +447,7 @@ var SheetDeleteFilterViewCondition = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
data, err := runtime.CallAPITyped("DELETE",
|
||||
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
@@ -464,7 +464,7 @@ func validateExpectedFlag(s string) error {
|
||||
}
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expected must be a JSON array (e.g. [\"6\"]), got: %s", s).WithParam("--expected")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -115,10 +115,10 @@ var SheetMediaUpload = common.Shortcut{
|
||||
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return "", nil, common.WrapInputStatError(err, "file not found")
|
||||
return "", nil, common.WrapInputStatErrorTyped(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
return filePath, stat, nil
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func validateFloatImageRange(sheetID, rangeVal string) error {
|
||||
return err
|
||||
}
|
||||
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
|
||||
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range prefix %q does not match --sheet-id %q", prefix, sheetID).WithParam("--range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
runtime.Cmd.Flags().Changed("offset-x") ||
|
||||
runtime.Cmd.Flags().Changed("offset-y")
|
||||
if !hasField {
|
||||
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -214,22 +214,22 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
func validateFloatImageDims(runtime *common.RuntimeContext) error {
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
if v := runtime.Int("width"); v < 20 {
|
||||
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be >= 20 pixels, got %d", v).WithParam("--width")
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
if v := runtime.Int("height"); v < 20 {
|
||||
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be >= 20 pixels, got %d", v).WithParam("--height")
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
if v := runtime.Int("offset-x"); v < 0 {
|
||||
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-x must be >= 0, got %d", v).WithParam("--offset-x")
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
if v := runtime.Int("offset-y"); v < 0 {
|
||||
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-y must be >= 0, got %d", v).WithParam("--offset-y")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -304,7 +304,7 @@ var SheetCreateFloatImage = common.Shortcut{
|
||||
if s := runtime.Str("float-image-id"); s != "" {
|
||||
body["float_image_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
data, err := runtime.CallAPITyped("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,7 +353,7 @@ var SheetUpdateFloatImage = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
body := buildFloatImageBody(runtime, false)
|
||||
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
|
||||
data, err := runtime.CallAPITyped("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -387,7 +387,7 @@ var SheetGetFloatImage = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -420,7 +420,7 @@ var SheetListFloatImages = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
data, err := runtime.CallAPITyped("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -454,7 +454,7 @@ var SheetDeleteFloatImage = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFloatImageToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
data, err := runtime.CallAPITyped("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -31,7 +32,7 @@ var SheetAddDimension = common.Shortcut{
|
||||
}
|
||||
length := runtime.Int("length")
|
||||
if length < 1 || length > 5000 {
|
||||
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--length must be between 1 and 5000, got %d", length).WithParam("--length")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -51,7 +52,7 @@ var SheetAddDimension = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -91,10 +92,10 @@ var SheetInsertDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
|
||||
}
|
||||
if runtime.Int("end-index") <= runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be greater than --start-index")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be greater than --start-index").WithParam("--end-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -131,7 +132,7 @@ var SheetInsertDimension = common.Shortcut{
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
|
||||
nil, body,
|
||||
)
|
||||
@@ -165,16 +166,16 @@ var SheetUpdateDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --visible or --fixed-size")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
|
||||
return common.FlagErrorf("--fixed-size must be >= 1")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fixed-size must be >= 1").WithParam("--fixed-size")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -211,7 +212,7 @@ var SheetUpdateDimension = common.Shortcut{
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
data, err := runtime.CallAPITyped("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -253,13 +254,13 @@ var SheetMoveDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
|
||||
}
|
||||
if runtime.Int("destination-index") < 0 {
|
||||
return common.FlagErrorf("--destination-index must be >= 0")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--destination-index must be >= 0").WithParam("--destination-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -281,7 +282,7 @@ var SheetMoveDimension = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(runtime.Str("sheet-id")),
|
||||
@@ -324,10 +325,10 @@ var SheetDeleteDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -348,7 +349,7 @@ var SheetDeleteDimension = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
data, err := runtime.CallAPITyped("DELETE",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
|
||||
@@ -76,6 +76,23 @@ func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportDryRunRejectsUnsafeOutputPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetExport, []string{
|
||||
"+export",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--file-extension", "xlsx",
|
||||
"--output-path", "../escape.xlsx",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Fatalf("expected unsafe output-path validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@ package backward
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
@@ -402,38 +401,26 @@ func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.
|
||||
t.Fatal("expected move failure, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Code != 1310211 {
|
||||
t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code)
|
||||
if p.Code != 1310211 {
|
||||
t.Fatalf("error code = %d, want 1310211", p.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) {
|
||||
t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message)
|
||||
if !strings.Contains(p.Message, `sheet copied successfully as "sheet_copy"`) {
|
||||
t.Fatalf("message missing copied sheet id: %q", p.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") {
|
||||
t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "do not retry +copy-sheet") {
|
||||
t.Fatalf("hint missing retry guard: %q", p.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") {
|
||||
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
|
||||
// The recovery command in the hint is the AI-actionable signal: retry only
|
||||
// the move (not the whole +copy-sheet, which would duplicate the sheet).
|
||||
if !strings.Contains(p.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") {
|
||||
t.Fatalf("hint missing recovery command: %q", p.Hint)
|
||||
}
|
||||
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["partial_success"] != true {
|
||||
t.Fatalf("partial_success = %#v, want true", detail["partial_success"])
|
||||
}
|
||||
if detail["sheet_id"] != "sheet_copy" {
|
||||
t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy")
|
||||
}
|
||||
if detail["requested_index"] != 2 {
|
||||
t.Fatalf("requested_index = %#v, want 2", detail["requested_index"])
|
||||
}
|
||||
if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" {
|
||||
t.Fatalf("retry_command = %#v", detail["retry_command"])
|
||||
}
|
||||
if detail["log_id"] != "log-move-failed" {
|
||||
t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed")
|
||||
if p.LogID != "log-move-failed" {
|
||||
t.Fatalf("log_id = %q, want %q", p.LogID, "log-move-failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
@@ -21,63 +20,63 @@ func sheetBatchUpdatePath(token string) string {
|
||||
}
|
||||
|
||||
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
|
||||
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--spreadsheet-token").WithCause(err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
if url == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/<token>").WithParam("--url")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func validateSheetID(flagName, sheetID string) error {
|
||||
if strings.TrimSpace(sheetID) == "" {
|
||||
return common.FlagErrorf("specify --%s", flagName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --%s", flagName).WithParam("--" + flagName)
|
||||
}
|
||||
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSheetTitle(flagName, title string) error {
|
||||
if title == "" {
|
||||
return common.FlagErrorf("--%s must not be empty", flagName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not be empty", flagName).WithParam("--" + flagName)
|
||||
}
|
||||
if strings.ContainsAny(title, "\t\r\n") {
|
||||
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain tabs or line breaks", flagName).WithParam("--" + flagName)
|
||||
}
|
||||
if err := validate.RejectControlChars(title, flagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
|
||||
}
|
||||
if len([]rune(title)) > 100 {
|
||||
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be <= 100 characters", flagName).WithParam("--" + flagName)
|
||||
}
|
||||
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
|
||||
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain any of / \\ ? * [ ] :", flagName).WithParam("--" + flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNonNegativeInt(flagName string, value int) error {
|
||||
if value < 0 {
|
||||
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be >= 0, got %d", flagName, value).WithParam("--" + flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -287,36 +286,18 @@ func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interfac
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} {
|
||||
if len(overlay) == 0 {
|
||||
return detail
|
||||
}
|
||||
if detail == nil {
|
||||
return overlay
|
||||
}
|
||||
if existing, ok := detail.(map[string]interface{}); ok {
|
||||
merged := map[string]interface{}{}
|
||||
for k, v := range existing {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range overlay {
|
||||
merged[k] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
merged := map[string]interface{}{}
|
||||
for k, v := range overlay {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["cause_detail"] = detail
|
||||
return merged
|
||||
}
|
||||
|
||||
func copySheetMoveRetryCommand(token, sheetID string, index int) string {
|
||||
return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index)
|
||||
}
|
||||
|
||||
// wrapCopySheetMoveError reports a +copy-sheet that created the new sheet but
|
||||
// then failed to move it to the requested index. The copy already succeeded, so
|
||||
// the recovery is to retry only the move (not the whole +copy-sheet, which would
|
||||
// duplicate the sheet) — that guard and the exact retry command go into the
|
||||
// hint. The underlying move error is already a typed errs.* error from
|
||||
// CallAPITyped; its category/subtype/code/log_id are preserved in place
|
||||
// (mirroring drive's enrichDriveSearchError) so the failure stays accurately
|
||||
// classified, with only the partial-success context folded into message and hint.
|
||||
func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
|
||||
if strings.TrimSpace(sheetID) == "" {
|
||||
return err
|
||||
@@ -329,46 +310,22 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
|
||||
sheetID,
|
||||
retryCommand,
|
||||
)
|
||||
detail := map[string]interface{}{
|
||||
"partial_success": true,
|
||||
"failed_step": "move_copied_sheet",
|
||||
"spreadsheet_token": token,
|
||||
"sheet_id": sheetID,
|
||||
"requested_index": index,
|
||||
"retry_command": retryCommand,
|
||||
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if upstream := strings.TrimSpace(p.Message); upstream != "" {
|
||||
p.Message = fmt.Sprintf("%s: %s", msg, upstream)
|
||||
} else {
|
||||
p.Message = msg
|
||||
}
|
||||
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
|
||||
p.Hint = upstreamHint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" {
|
||||
hint = upstreamHint + "\n" + hint
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message),
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
|
||||
},
|
||||
Err: err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Message: fmt.Sprintf("%s: %v", msg, err),
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).WithHint(hint).WithCause(err)
|
||||
}
|
||||
|
||||
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
@@ -397,7 +354,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
if runtime.Changed("lock-info") {
|
||||
if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--lock-info").WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,24 +362,24 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
if hasProtectConfig {
|
||||
lock := runtime.Str("lock")
|
||||
if !runtime.Changed("lock") {
|
||||
return common.FlagErrorf("specify --lock when updating protection settings")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --lock when updating protection settings").WithParam("--lock")
|
||||
}
|
||||
if runtime.Changed("lock-info") && lock != "LOCK" {
|
||||
return common.FlagErrorf("--lock-info requires --lock LOCK")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--lock-info requires --lock LOCK").WithParam("--lock-info")
|
||||
}
|
||||
if runtime.Changed("user-ids") {
|
||||
if lock != "LOCK" {
|
||||
return common.FlagErrorf("--user-ids requires --lock LOCK")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --lock LOCK").WithParam("--user-ids")
|
||||
}
|
||||
if runtime.Str("user-id-type") == "" {
|
||||
return common.FlagErrorf("--user-ids requires --user-id-type")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --user-id-type").WithParam("--user-id-type")
|
||||
}
|
||||
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return common.FlagErrorf("--user-ids must not be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids must not be empty").WithParam("--user-ids")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,7 +391,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
runtime.Changed("frozen-col-count") ||
|
||||
hasProtectConfig
|
||||
if !hasUpdate {
|
||||
return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -530,7 +487,7 @@ var SheetCreateSheet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,7 +550,7 @@ var SheetCopySheet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -604,7 +561,7 @@ var SheetCopySheet = common.Shortcut{
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
copiedSheetID, _ := out["sheet_id"].(string)
|
||||
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
|
||||
moveResp, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
|
||||
if err != nil {
|
||||
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
|
||||
}
|
||||
@@ -644,7 +601,7 @@ var SheetDeleteSheet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -707,7 +664,7 @@ var SheetUpdateSheet = common.Shortcut{
|
||||
params = map[string]interface{}{"user_id_type": userIDType}
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body)
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ var SheetInfo = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,7 +55,7 @@ var SheetInfo = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
|
||||
spreadsheetData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,13 +95,13 @@ var SheetCreate = common.Shortcut{
|
||||
if headersStr := runtime.Str("headers"); headersStr != "" {
|
||||
var headers []interface{}
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
|
||||
}
|
||||
}
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -129,7 +129,7 @@ var SheetCreate = common.Shortcut{
|
||||
if headersStr != "" {
|
||||
var headers []interface{}
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
allRows = append(allRows, any(headers))
|
||||
@@ -139,7 +139,7 @@ var SheetCreate = common.Shortcut{
|
||||
if dataStr != "" {
|
||||
var rows []interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
allRows = append(allRows, rows...)
|
||||
@@ -151,7 +151,7 @@ var SheetCreate = common.Shortcut{
|
||||
createData["folder_token"] = folderToken
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +164,7 @@ var SheetCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
if _, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
"range": appendRange,
|
||||
"values": allRows,
|
||||
@@ -211,8 +211,11 @@ var SheetExport = common.Shortcut{
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetExportOutputPath(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
|
||||
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sheet-id is required when --file-extension is csv").WithParam("--sheet-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -238,10 +241,8 @@ var SheetExport = common.Shortcut{
|
||||
outputPath := runtime.Str("output-path")
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
|
||||
if outputPath != "" {
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := validateSheetExportOutputPath(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exportData := map[string]interface{}{
|
||||
@@ -253,7 +254,7 @@ var SheetExport = common.Shortcut{
|
||||
exportData["sub_id"] = sheetID
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -280,7 +281,7 @@ var SheetExport = common.Shortcut{
|
||||
}
|
||||
|
||||
if fileToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "export task timed out").WithRetryable()
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
|
||||
@@ -298,7 +299,7 @@ var SheetExport = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
return wrapSheetsNetworkErr(err, "download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -307,7 +308,7 @@ var SheetExport = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
@@ -321,3 +322,14 @@ var SheetExport = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateSheetExportOutputPath(runtime *common.RuntimeContext) error {
|
||||
outputPath := strings.TrimSpace(runtime.Str("output-path"))
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output-path").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
15
shortcuts/sheets/backward/sheets_errors.go
Normal file
15
shortcuts/sheets/backward/sheets_errors.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package backward
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wrapSheetsNetworkErr preserves typed boundary errors and only classifies raw
|
||||
// transport failures that still surface from stream/download paths.
|
||||
func wrapSheetsNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
func rejectLocalImageInBatch(fv flagView) error {
|
||||
if strings.TrimSpace(fv.Str("image")) != "" {
|
||||
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
|
||||
return common.ValidationErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -208,23 +208,23 @@ func rejectLocalImageInBatch(fv flagView) error {
|
||||
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
|
||||
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if sheetID == "" {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
|
||||
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
|
||||
}
|
||||
if !fv.Changed("source-index") {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
|
||||
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
|
||||
}
|
||||
if fv.Int("source-index") < 0 {
|
||||
return nil, common.FlagErrorf("--source-index must be >= 0")
|
||||
return nil, common.ValidationErrorf("--source-index must be >= 0")
|
||||
}
|
||||
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
|
||||
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
|
||||
// which would silently move the sheet to the front. Require it explicitly so
|
||||
// the batch contract matches the standalone one.
|
||||
if !fv.Changed("index") {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
|
||||
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires index")
|
||||
}
|
||||
if fv.Int("index") < 0 {
|
||||
return nil, common.FlagErrorf("--index must be >= 0")
|
||||
return nil, common.ValidationErrorf("--index must be >= 0")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -254,19 +254,19 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
|
||||
return nil, common.ValidationErrorf("operations[%d] must be a JSON object", index)
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
|
||||
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' field is required", index)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
|
||||
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(
|
||||
return nil, common.ValidationErrorf(
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||
@@ -280,12 +280,12 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
} else {
|
||||
input, ok = inputRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
|
||||
return nil, common.ValidationErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
|
||||
}
|
||||
}
|
||||
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
|
||||
if _, has := input["operation"]; has {
|
||||
return nil, common.FlagErrorf(
|
||||
return nil, common.ValidationErrorf(
|
||||
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
|
||||
index, sc,
|
||||
)
|
||||
@@ -293,7 +293,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, common.FlagErrorf(
|
||||
return nil, common.ValidationErrorf(
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, k,
|
||||
)
|
||||
@@ -302,7 +302,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// 拒绝任何额外的 sub-op 顶层 key(防御未来 schema drift / 用户笔误)。
|
||||
for k := range op {
|
||||
if k != "shortcut" && k != "input" {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
return nil, common.ValidationErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
@@ -310,14 +310,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
|
||||
if err := fv.validateRawTypes(); err != nil {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
return nil, common.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
|
||||
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
return nil, common.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": mapping.mcpToolName,
|
||||
@@ -328,7 +328,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
|
||||
return nil, common.ValidationErrorf("--operations must be a non-empty JSON array")
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
|
||||
@@ -21,7 +21,6 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
|
||||
{"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"},
|
||||
{"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"},
|
||||
{"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"},
|
||||
{"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -38,6 +37,21 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{
|
||||
"csv": "a,b",
|
||||
"start-cell": "C3",
|
||||
"range": "A1:H17",
|
||||
})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
|
||||
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
|
||||
// call instead of silently anchoring at the "A1" flag default. Standalone never
|
||||
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
||||
@@ -453,16 +453,15 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
|
||||
if !strings.Contains(p.Message, "shtNEW") {
|
||||
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
|
||||
if !strings.Contains(p.Hint, "spreadsheet_token") {
|
||||
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
return common.FlagErrorf("--%s: %s", name, vErr.Error())
|
||||
return common.ValidationErrorf("--%s: %s", name, vErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,20 +12,40 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func sheetsFlagParam(name string) string {
|
||||
if strings.HasPrefix(name, "--") {
|
||||
return name
|
||||
}
|
||||
return "--" + name
|
||||
}
|
||||
|
||||
func sheetsInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: sheetsFlagParam(name), Reason: reason}
|
||||
}
|
||||
|
||||
func sheetsValidationForFlag(name, format string, args ...any) *errs.ValidationError {
|
||||
return common.ValidationErrorf(format, args...).WithParam(sheetsFlagParam(name))
|
||||
}
|
||||
|
||||
func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationError {
|
||||
return common.ValidationErrorf("%v", cause).WithParam(sheetsFlagParam(name)).WithCause(cause)
|
||||
}
|
||||
|
||||
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
|
||||
// pair shared by every sheets canonical shortcut and returns the resolved
|
||||
// token. Network-free, safe to call from Validate and DryRun.
|
||||
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
|
||||
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -33,10 +53,10 @@ func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
return "", sheetsValidationCauseForFlag("url", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -64,18 +84,18 @@ func extractSpreadsheetToken(input string) string {
|
||||
// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers
|
||||
// pass both through to the tool input; the server picks whichever fits.
|
||||
func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) {
|
||||
if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil {
|
||||
if err := common.ExactlyOneTyped(runtime, "sheet-id", "sheet-name"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" {
|
||||
if err := validate.RejectControlChars(id, "sheet-id"); err != nil {
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
return "", "", sheetsValidationCauseForFlag("sheet-id", err)
|
||||
}
|
||||
return id, "", nil
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("sheet-name"))
|
||||
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
return "", "", sheetsValidationCauseForFlag("sheet-name", err)
|
||||
}
|
||||
return "", name, nil
|
||||
}
|
||||
@@ -116,18 +136,26 @@ func requireSheetSelector(sheetID, sheetName string) error {
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID == "" && sheetName == "" {
|
||||
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
|
||||
return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "required; specify at least one"),
|
||||
sheetsInvalidParam("sheet-name", "required; specify at least one"),
|
||||
)
|
||||
}
|
||||
if sheetID != "" && sheetName != "" {
|
||||
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
|
||||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "mutually exclusive"),
|
||||
sheetsInvalidParam("sheet-name", "mutually exclusive"),
|
||||
)
|
||||
}
|
||||
if sheetID != "" {
|
||||
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return sheetsValidationCauseForFlag("sheet-id", err)
|
||||
}
|
||||
} else {
|
||||
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return sheetsValidationCauseForFlag("sheet-name", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -152,15 +180,19 @@ func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string)
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID != "" && sheetName != "" {
|
||||
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
|
||||
return common.ValidationErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName).
|
||||
WithParams(
|
||||
sheetsInvalidParam(idFlagName, "mutually exclusive"),
|
||||
sheetsInvalidParam(nameFlagName, "mutually exclusive"),
|
||||
)
|
||||
}
|
||||
if sheetID != "" {
|
||||
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return sheetsValidationCauseForFlag(idFlagName, err)
|
||||
}
|
||||
} else if sheetName != "" {
|
||||
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
return sheetsValidationCauseForFlag(nameFlagName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -197,7 +229,7 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
@@ -216,11 +248,11 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
return nil, sheetsValidationForFlag(name, "--%s is required", name)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object", name)
|
||||
return nil, sheetsValidationForFlag(name, "--%s must be a JSON object", name)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -232,11 +264,11 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
return nil, sheetsValidationForFlag(name, "--%s is required", name)
|
||||
}
|
||||
a, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array", name)
|
||||
return nil, sheetsValidationForFlag(name, "--%s must be a JSON array", name)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -293,7 +325,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--border-styles must be a JSON object")
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -307,5 +339,10 @@ func requireAnyStyleFlag(runtime flagView) error {
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ package sheets
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/httpmock"
|
||||
@@ -79,6 +81,71 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestSheetHelpersValidationMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing sheet selector reports both params", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := requireSheetSelector("", "")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("params = %#v, want two structured params", validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
|
||||
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "sheets"}
|
||||
cmd.Flags().String("url", "not-a-sheet-url", "")
|
||||
cmd.Flags().String("spreadsheet-token", "", "")
|
||||
_, err := resolveSpreadsheetToken(common.TestNewRuntimeContext(cmd, testConfig(t)))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != "--url" {
|
||||
t.Fatalf("param = %q, want --url", validationErr.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet selector control char keeps param and cause", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := requireSheetSelector("bad\x00id", "")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != "--sheet-id" {
|
||||
t.Fatalf("param = %q, want --sheet-id", validationErr.Param)
|
||||
}
|
||||
if validationErr.Unwrap() == nil {
|
||||
t.Fatalf("expected control-char validation cause to be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json flag keeps param and cause", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := newMapFlagViewForCommand("+cells-set", map[string]interface{}{"cells": "{"})
|
||||
_, err := parseJSONFlag(fv, "cells")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != "--cells" {
|
||||
t.Fatalf("param = %q, want --cells", validationErr.Param)
|
||||
}
|
||||
if validationErr.Unwrap() == nil {
|
||||
t.Fatalf("expected JSON parse cause to be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// parseDryRunBody runs the shortcut in --dry-run and returns the first
|
||||
// api call's body. The dry-run output format is:
|
||||
//
|
||||
|
||||
@@ -132,7 +132,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--operations is required")
|
||||
return nil, common.ValidationErrorf("--operations is required")
|
||||
}
|
||||
if arr, ok := v.([]interface{}); ok {
|
||||
return arr, nil
|
||||
@@ -142,7 +142,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
|
||||
return ops, nil
|
||||
}
|
||||
}
|
||||
return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
|
||||
return nil, common.ValidationErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
|
||||
}
|
||||
|
||||
// CellsBatchSetStyle stamps one style block across many sheet-prefixed
|
||||
@@ -222,7 +222,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
}
|
||||
rows, cols, err := rangeDimensions(sub)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("range %q: %v", rng, err)
|
||||
return nil, common.ValidationErrorf("range %q: %v", rng, err)
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
@@ -386,7 +386,7 @@ var DropdownDelete = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if len(ranges) > 100 {
|
||||
return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
return common.ValidationErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -439,7 +439,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
}
|
||||
rows, cols, err := rangeDimensions(sub)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("range %q: %v", rng, err)
|
||||
return nil, common.ValidationErrorf("range %q: %v", rng, err)
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
@@ -471,21 +471,21 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
for i, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
|
||||
return nil, common.ValidationErrorf("--ranges[%d] must be a string", i)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.Contains(s, "!") {
|
||||
return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
|
||||
return nil, common.ValidationErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
|
||||
}
|
||||
// Validate the sheet!range shape up front so malformed entries like
|
||||
// "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail
|
||||
// here at Validate instead of slipping through to DryRun/Execute.
|
||||
_, sub, err := splitSheetPrefixedRange(s)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
|
||||
return nil, common.ValidationErrorf("--ranges[%d]: %v", i, err)
|
||||
}
|
||||
if _, _, err := rangeDimensions(sub); err != nil {
|
||||
return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err)
|
||||
return nil, common.ValidationErrorf("--ranges[%d] (%q): %v", i, s, err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
@@ -496,7 +496,7 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) {
|
||||
idx := strings.Index(rng, "!")
|
||||
if idx <= 0 || idx == len(rng)-1 {
|
||||
return "", "", common.FlagErrorf("range %q must use sheet!range form", rng)
|
||||
return "", "", common.ValidationErrorf("range %q must use sheet!range form", rng)
|
||||
}
|
||||
return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -251,7 +251,7 @@ func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
return nil, err
|
||||
}
|
||||
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
|
||||
return nil, common.FlagErrorf("--%s is required", spec.idFlag)
|
||||
return nil, common.ValidationErrorf("--%s is required", spec.idFlag)
|
||||
}
|
||||
props, err := requireJSONObject(runtime, "properties")
|
||||
if err != nil {
|
||||
@@ -335,7 +335,7 @@ func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
return nil, err
|
||||
}
|
||||
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
|
||||
return nil, common.FlagErrorf("--%s is required", spec.idFlag)
|
||||
return nil, common.ValidationErrorf("--%s is required", spec.idFlag)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -517,16 +517,16 @@ func validateSparklineUpdateItems(input map[string]interface{}) error {
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("+sparkline-update properties.sparklines must be an array")
|
||||
return common.ValidationErrorf("+sparkline-update properties.sparklines must be an array")
|
||||
}
|
||||
for i, item := range arr {
|
||||
m, _ := item.(map[string]interface{})
|
||||
if m == nil {
|
||||
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
|
||||
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
|
||||
}
|
||||
id, _ := m["sparkline_id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
|
||||
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -595,20 +595,20 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
|
||||
}
|
||||
}
|
||||
if set == 0 && requireImageSource {
|
||||
return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required")
|
||||
return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required")
|
||||
}
|
||||
if set > 1 {
|
||||
return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive")
|
||||
return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive")
|
||||
}
|
||||
name := floatImageName(runtime)
|
||||
if name == "" {
|
||||
return nil, common.FlagErrorf("--image-name is required")
|
||||
return nil, common.ValidationErrorf("--image-name is required")
|
||||
}
|
||||
if !runtime.Changed("position-row") || !runtime.Changed("position-col") {
|
||||
return nil, common.FlagErrorf("--position-row and --position-col are required")
|
||||
return nil, common.ValidationErrorf("--position-row and --position-col are required")
|
||||
}
|
||||
if !runtime.Changed("size-width") || !runtime.Changed("size-height") {
|
||||
return nil, common.FlagErrorf("--size-width and --size-height are required")
|
||||
return nil, common.ValidationErrorf("--size-width and --size-height are required")
|
||||
}
|
||||
props := map[string]interface{}{
|
||||
"image_name": name,
|
||||
@@ -626,7 +626,9 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
|
||||
// Local file: validate path safety here so --dry-run also rejects
|
||||
// unsafe paths; Execute uploads it and passes the real token in.
|
||||
if _, err := validate.SafeLocalFlagPath("--image", img); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
}
|
||||
if uploadedImageToken != "" {
|
||||
props["image_token"] = uploadedImageToken
|
||||
@@ -746,7 +748,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(img)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return "", common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: img,
|
||||
@@ -762,7 +764,7 @@ func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string
|
||||
return nil, err
|
||||
}
|
||||
if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" {
|
||||
return nil, common.FlagErrorf("--float-image-id is required")
|
||||
return nil, common.ValidationErrorf("--float-image-id is required")
|
||||
}
|
||||
props, err := floatImageProperties(runtime, uploadedImageToken, op == "create")
|
||||
if err != nil {
|
||||
@@ -882,7 +884,7 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
props := map[string]interface{}{
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
@@ -957,10 +959,10 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[
|
||||
return nil, err
|
||||
}
|
||||
if sheetID == "" {
|
||||
return nil, common.FlagErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
|
||||
return nil, common.ValidationErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
props, err := requireJSONObject(runtime, "properties")
|
||||
if err != nil {
|
||||
@@ -1031,7 +1033,7 @@ func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[
|
||||
return nil, err
|
||||
}
|
||||
if sheetID == "" {
|
||||
return nil, common.FlagErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
|
||||
return nil, common.ValidationErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
|
||||
@@ -5,10 +5,9 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -76,7 +75,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -108,22 +107,22 @@ func normalizeClearType(scope string) string {
|
||||
// pivot-occupied A1 with cells-clear; point the agent at the object's own
|
||||
// delete command instead. Non-matching errors pass through untouched.
|
||||
func annotateEmbeddedBlockClearErr(err error) error {
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) || ee.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") {
|
||||
if !strings.Contains(strings.ToLower(p.Message), "embedded block") {
|
||||
return err
|
||||
}
|
||||
const hint = "the range overlaps an embedded object (pivot table / chart); " +
|
||||
"cells-clear only clears cell values/formats and cannot delete it — " +
|
||||
"delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)"
|
||||
if ee.Detail.Hint == "" {
|
||||
ee.Detail.Hint = hint
|
||||
if p.Hint == "" {
|
||||
p.Hint = hint
|
||||
} else {
|
||||
ee.Detail.Hint += "; " + hint
|
||||
p.Hint += "; " + hint
|
||||
}
|
||||
return ee
|
||||
return err
|
||||
}
|
||||
|
||||
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
|
||||
@@ -191,7 +190,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -345,36 +344,36 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("range") {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers (e.g. \"2:10\")"
|
||||
if dimension == "column" {
|
||||
want = "column letters (e.g. \"A:E\")"
|
||||
}
|
||||
return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
|
||||
return nil, common.ValidationErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
|
||||
}
|
||||
if !strings.Contains(rangeStr, ":") {
|
||||
rangeStr = rangeStr + ":" + rangeStr
|
||||
}
|
||||
typ := strings.TrimSpace(runtime.Str("type"))
|
||||
if typ == "" {
|
||||
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
return nil, common.ValidationErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
}
|
||||
if dimension == "column" && typ == "auto" {
|
||||
return nil, common.FlagErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
|
||||
return nil, common.ValidationErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
|
||||
}
|
||||
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
|
||||
if typ == "pixel" && !hasSize {
|
||||
return nil, common.FlagErrorf("--type pixel requires --size <px>")
|
||||
return nil, common.ValidationErrorf("--type pixel requires --size <px>")
|
||||
}
|
||||
if typ != "pixel" && hasSize {
|
||||
return nil, common.FlagErrorf("--size is only valid with --type pixel")
|
||||
return nil, common.ValidationErrorf("--size is only valid with --type pixel")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -567,10 +566,10 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("source-range")) == "" {
|
||||
return nil, common.FlagErrorf("--source-range is required")
|
||||
return nil, common.ValidationErrorf("--source-range is required")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("target-range")) == "" {
|
||||
return nil, common.FlagErrorf("--target-range is required")
|
||||
return nil, common.ValidationErrorf("--target-range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -609,10 +608,10 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("source-range")) == "" {
|
||||
return nil, common.FlagErrorf("--source-range is required")
|
||||
return nil, common.ValidationErrorf("--source-range is required")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("target-range")) == "" {
|
||||
return nil, common.FlagErrorf("--target-range is required")
|
||||
return nil, common.ValidationErrorf("--target-range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -641,7 +640,7 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
// requireJSONArray runs the embedded JSON Schema for --sort-keys
|
||||
// via parseJSONFlag → validateParsedJSONFlag, so each item is
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -16,34 +16,35 @@ func TestAnnotateEmbeddedBlockClearErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
|
||||
Type: "api",
|
||||
Message: `tool "clear_cell_range" failed: [500] can not find embedded block`,
|
||||
}}
|
||||
var ee *output.ExitError
|
||||
if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil {
|
||||
t.Fatal("expected ExitError with detail")
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, `tool "clear_cell_range" failed: [500] can not find embedded block`)
|
||||
p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in))
|
||||
if !ok {
|
||||
t.Fatal("expected typed problem")
|
||||
}
|
||||
if !strings.Contains(ee.Detail.Hint, "+pivot-delete") {
|
||||
t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "+pivot-delete") {
|
||||
t.Errorf("hint should point at +pivot-delete, got %q", p.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends to existing hint", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
|
||||
Message: "embedded block missing", Hint: "preexisting",
|
||||
}}
|
||||
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
|
||||
if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") {
|
||||
t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint)
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "embedded block missing").WithHint("preexisting")
|
||||
p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in))
|
||||
if !ok {
|
||||
t.Fatal("expected typed problem")
|
||||
}
|
||||
if !strings.HasPrefix(p.Hint, "preexisting; ") {
|
||||
t.Errorf("existing hint should be preserved and appended, got %q", p.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes through unrelated ExitError untouched", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}}
|
||||
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
|
||||
if out.Detail.Hint != "" {
|
||||
t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint)
|
||||
t.Run("passes through unrelated typed error untouched", func(t *testing.T) {
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "some other failure")
|
||||
p, ok := errs.ProblemOf(annotateEmbeddedBlockClearErr(in))
|
||||
if !ok {
|
||||
t.Fatal("expected typed problem")
|
||||
}
|
||||
if p.Hint != "" {
|
||||
t.Errorf("unrelated error should not gain a hint, got %q", p.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
return common.ValidationErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -142,7 +142,7 @@ var CsvGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
return common.ValidationErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -484,7 +484,7 @@ var DropdownGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
return common.ValidationErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ var CellsSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("find")) == "" {
|
||||
return common.FlagErrorf("--find is required")
|
||||
return common.ValidationErrorf("--find is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -151,10 +151,10 @@ func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[strin
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("find")) == "" {
|
||||
return nil, common.FlagErrorf("--find is required")
|
||||
return nil, common.ValidationErrorf("--find is required")
|
||||
}
|
||||
if !runtime.Changed("replacement") {
|
||||
return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)")
|
||||
return nil, common.ValidationErrorf("--replacement is required (pass an empty string to delete matches)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
|
||||
@@ -164,18 +164,18 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("position") {
|
||||
return nil, common.FlagErrorf("--position is required")
|
||||
return nil, common.ValidationErrorf("--position is required")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, common.FlagErrorf("--count is required")
|
||||
return nil, common.ValidationErrorf("--count is required")
|
||||
}
|
||||
position := strings.TrimSpace(runtime.Str("position"))
|
||||
if _, _, err := parseA1Position(position); err != nil {
|
||||
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
|
||||
return nil, common.ValidationErrorf("invalid --position %q: %v", position, err)
|
||||
}
|
||||
count := runtime.Int("count")
|
||||
if count <= 0 {
|
||||
return nil, common.FlagErrorf("--count must be > 0 (got %d)", count)
|
||||
return nil, common.ValidationErrorf("--count must be > 0 (got %d)", count)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -326,13 +326,13 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("dimension") {
|
||||
return nil, common.FlagErrorf("--dimension is required")
|
||||
return nil, common.ValidationErrorf("--dimension is required")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
|
||||
return nil, common.ValidationErrorf("--count is required (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, common.FlagErrorf("--count must be >= 0")
|
||||
return nil, common.ValidationErrorf("--count must be >= 0")
|
||||
}
|
||||
dim := runtime.Str("dimension")
|
||||
count := runtime.Int("count")
|
||||
@@ -361,11 +361,11 @@ func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (ma
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("range") {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if _, _, _, err := parseA1Range(rangeStr); err != nil {
|
||||
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -611,7 +611,7 @@ var DimMove = common.Shortcut{
|
||||
}
|
||||
sheetID = lookedID
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
|
||||
data, err := runtime.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -632,20 +632,20 @@ type dimMovePlan struct {
|
||||
// target dimension matches the source. Used by both Validate and Execute.
|
||||
func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) {
|
||||
if !runtime.Changed("source-range") || !runtime.Changed("target") {
|
||||
return nil, common.FlagErrorf("--source-range and --target are required")
|
||||
return nil, common.ValidationErrorf("--source-range and --target are required")
|
||||
}
|
||||
src := strings.TrimSpace(runtime.Str("source-range"))
|
||||
dim, startIdx, endIdx, err := parseA1Range(src)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
|
||||
return nil, common.ValidationErrorf("invalid --source-range %q: %v", src, err)
|
||||
}
|
||||
tgt := strings.TrimSpace(runtime.Str("target"))
|
||||
tgtDim, tgtIdx, err := parseA1Position(tgt)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
|
||||
return nil, common.ValidationErrorf("invalid --target %q: %v", tgt, err)
|
||||
}
|
||||
if tgtDim != dim {
|
||||
return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
|
||||
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
|
||||
}
|
||||
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -122,13 +122,13 @@ var SheetCreate = common.Shortcut{
|
||||
|
||||
func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.FlagErrorf("--title is required")
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
}
|
||||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||||
return nil, common.FlagErrorf("--row-count must be between 0 and 50000")
|
||||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||||
}
|
||||
if n := runtime.Int("col-count"); n < 0 || n > 200 {
|
||||
return nil, common.FlagErrorf("--col-count must be between 0 and 200")
|
||||
return nil, common.ValidationErrorf("--col-count must be between 0 and 200")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -167,7 +167,7 @@ func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[s
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.FlagErrorf("--title is required")
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -192,7 +192,7 @@ func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) (
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("color") {
|
||||
return nil, common.FlagErrorf("--color is required (empty string clears)")
|
||||
return nil, common.ValidationErrorf("--color is required (empty string clears)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -311,13 +311,13 @@ var SheetMove = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if !runtime.Changed("index") {
|
||||
return common.FlagErrorf("--index is required")
|
||||
return common.ValidationErrorf("--index is required")
|
||||
}
|
||||
if runtime.Int("index") < 0 {
|
||||
return common.FlagErrorf("--index must be >= 0")
|
||||
return common.ValidationErrorf("--index must be >= 0")
|
||||
}
|
||||
if runtime.Changed("source-index") && runtime.Int("source-index") < 0 {
|
||||
return common.FlagErrorf("--source-index must be >= 0")
|
||||
return common.ValidationErrorf("--source-index must be >= 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -561,7 +561,7 @@ var WorkbookCreate = common.Shortcut{
|
||||
Flags: flagsFor("+workbook-create"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return common.FlagErrorf("--title is required")
|
||||
return common.ValidationErrorf("--title is required")
|
||||
}
|
||||
if runtime.Str("headers") != "" {
|
||||
v, err := parseJSONFlag(runtime, "headers")
|
||||
@@ -569,7 +569,7 @@ var WorkbookCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
return common.FlagErrorf("--headers must be a JSON array")
|
||||
return common.ValidationErrorf("--headers must be a JSON array")
|
||||
}
|
||||
}
|
||||
if runtime.Str("values") != "" {
|
||||
@@ -579,11 +579,11 @@ var WorkbookCreate = common.Shortcut{
|
||||
}
|
||||
rows, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--values must be a JSON 2D array")
|
||||
return common.ValidationErrorf("--values must be a JSON 2D array")
|
||||
}
|
||||
for i, r := range rows {
|
||||
if _, ok := r.([]interface{}); !ok {
|
||||
return common.FlagErrorf("--values[%d] must be an array", i)
|
||||
return common.ValidationErrorf("--values[%d] must be an array", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,7 +613,7 @@ var WorkbookCreate = common.Shortcut{
|
||||
if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" {
|
||||
body["folder_token"] = v
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -623,7 +623,7 @@ var WorkbookCreate = common.Shortcut{
|
||||
token = common.GetString(ss, "token")
|
||||
}
|
||||
if token == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{"spreadsheet": ss}
|
||||
@@ -665,19 +665,9 @@ var WorkbookCreate = common.Shortcut{
|
||||
// not. The new spreadsheet_token is surfaced in the error detail so callers can
|
||||
// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only
|
||||
// finding the token interpolated into a bare error string.
|
||||
func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_success",
|
||||
Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason),
|
||||
Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it",
|
||||
Detail: map[string]interface{}{
|
||||
"spreadsheet_token": token,
|
||||
"spreadsheet": spreadsheet,
|
||||
},
|
||||
},
|
||||
}
|
||||
func workbookCreatedButFillFailed(token string, _ interface{}, reason string) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "spreadsheet %s created but %s", token, reason).
|
||||
WithHint("the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it")
|
||||
}
|
||||
|
||||
// buildInitialFillInput zips --headers + --values into a single set_cell_range
|
||||
@@ -765,7 +755,7 @@ var WorkbookExport = common.Shortcut{
|
||||
ext = "xlsx"
|
||||
}
|
||||
if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
|
||||
return common.FlagErrorf("--sheet-id is required when --file-extension=csv")
|
||||
return common.ValidationErrorf("--sheet-id is required when --file-extension=csv")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -813,13 +803,13 @@ var WorkbookExport = common.Shortcut{
|
||||
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
|
||||
body["sub_id"] = sid
|
||||
}
|
||||
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
taskData, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticket := common.GetString(taskData, "ticket")
|
||||
if ticket == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket missing")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
@@ -847,9 +837,9 @@ var WorkbookExport = common.Shortcut{
|
||||
continue
|
||||
default: // any non-zero status outside the in-progress window is a failure
|
||||
if status.JobErrorMsg != "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg)
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed: %s", ticket, status.JobErrorMsg)
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed with job_status=%d", ticket, status.JobStatus)
|
||||
}
|
||||
}
|
||||
if fileToken == "" {
|
||||
@@ -887,7 +877,7 @@ type exportTaskStatus struct {
|
||||
}
|
||||
|
||||
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
map[string]interface{}{"token": token},
|
||||
@@ -898,7 +888,7 @@ func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (expor
|
||||
}
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
|
||||
return exportTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "export task %s: empty result", ticket)
|
||||
}
|
||||
js, _ := util.ToFloat64(result["job_status"])
|
||||
fs, _ := util.ToFloat64(result["file_size"])
|
||||
@@ -918,10 +908,10 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
}, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return "", output.ErrNetwork("download failed: %s", err)
|
||||
return "", sheetsDownloadRequestError(err)
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
|
||||
return "", sheetsDownloadHTTPStatusError(apiResp)
|
||||
}
|
||||
target := outPath
|
||||
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
|
||||
@@ -935,7 +925,7 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
|
||||
ContentType: apiResp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(apiResp.RawBody)),
|
||||
}, strings.NewReader(string(apiResp.RawBody))); err != nil {
|
||||
return "", common.WrapSaveErrorByCategory(err, "io")
|
||||
return "", common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolved, _ := runtime.FileIO().ResolvePath(target)
|
||||
if resolved == "" {
|
||||
@@ -944,6 +934,57 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func sheetsDownloadRequestError(err error) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func sheetsDownloadHTTPStatusError(resp *larkcore.ApiResp) error {
|
||||
status := resp.StatusCode
|
||||
body := strings.TrimSpace(string(resp.RawBody))
|
||||
if body == "" {
|
||||
body = http.StatusText(status)
|
||||
}
|
||||
logID := sheetsDownloadResponseLogID(resp)
|
||||
if status >= http.StatusInternalServerError {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d: %s", status, body).
|
||||
WithCode(status).
|
||||
WithRetryable()
|
||||
if logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if status == http.StatusTooManyRequests {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "download failed: HTTP %d: %s", status, body).
|
||||
WithCode(status).
|
||||
WithRetryable()
|
||||
if logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
err := errs.NewAPIError(subtype, "download failed: HTTP %d: %s", status, body).WithCode(status)
|
||||
if logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func sheetsDownloadResponseLogID(resp *larkcore.ApiResp) string {
|
||||
logID := strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyLogId))
|
||||
if logID == "" {
|
||||
logID = strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyRequestId))
|
||||
}
|
||||
return logID
|
||||
}
|
||||
|
||||
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
|
||||
// id + current 0-based index. Caller is responsible for ensuring at least one
|
||||
// of sheetID/sheetName is non-empty.
|
||||
@@ -956,7 +997,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||||
}
|
||||
sheets, _ := m["sheets"].([]interface{})
|
||||
for _, raw := range sheets {
|
||||
@@ -975,7 +1016,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) {
|
||||
idx, ok := util.ToFloat64(sm["index"])
|
||||
if !ok {
|
||||
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field")
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field")
|
||||
}
|
||||
return id, int(idx), nil
|
||||
}
|
||||
@@ -984,7 +1025,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if target == "" {
|
||||
target = sheetName
|
||||
}
|
||||
return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target))
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target)
|
||||
}
|
||||
|
||||
// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the
|
||||
@@ -1001,7 +1042,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||||
}
|
||||
sheets, _ := m["sheets"].([]interface{})
|
||||
bestID := ""
|
||||
@@ -1029,7 +1070,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
|
||||
}
|
||||
}
|
||||
if bestID == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
|
||||
}
|
||||
return bestID, nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -391,6 +396,92 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves typed request errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
|
||||
got := sheetsDownloadRequestError(in)
|
||||
if got != in {
|
||||
t.Fatalf("typed error was not preserved: got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadRequestError(errors.New("dial refused"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantCategory errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetryable bool
|
||||
}{
|
||||
{
|
||||
name: "5xx is retryable network server error",
|
||||
status: http.StatusBadGateway,
|
||||
wantCategory: errs.CategoryNetwork,
|
||||
wantSubtype: errs.SubtypeNetworkServer,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "404 is API not found",
|
||||
status: http.StatusNotFound,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeNotFound,
|
||||
},
|
||||
{
|
||||
name: "429 is retryable API rate limit",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeRateLimit,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "other 4xx is API unknown",
|
||||
status: http.StatusForbidden,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
|
||||
StatusCode: tt.status,
|
||||
RawBody: []byte("body"),
|
||||
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
|
||||
})
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
|
||||
}
|
||||
if p.Code != tt.status {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tt.status)
|
||||
}
|
||||
if p.LogID != "log123" {
|
||||
t.Fatalf("log_id = %q, want log123", p.LogID)
|
||||
}
|
||||
if p.Retryable != tt.wantRetryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertInputEquals compares the decoded tool input map against the wanted
|
||||
// fields. Extra fields in `got` are allowed (defaults, optional fields);
|
||||
// every key in `want` must match exactly.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -82,7 +82,7 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
cells, err := requireJSONArray(runtime, "cells")
|
||||
if err != nil {
|
||||
@@ -156,11 +156,11 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if rangeStr == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
|
||||
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return nil, err
|
||||
@@ -218,6 +218,7 @@ var CsvPut = common.Shortcut{
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||||
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
|
||||
},
|
||||
Validate: validateViaInput(csvPutInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -300,7 +301,10 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("csv")) == "" {
|
||||
return nil, common.FlagErrorf("--csv is required")
|
||||
return nil, common.ValidationErrorf("--csv is required")
|
||||
}
|
||||
if runtime.Changed("start-cell") && runtime.Changed("range") {
|
||||
return nil, common.ValidationErrorf("--start-cell and --range are mutually exclusive")
|
||||
}
|
||||
anchor := strings.TrimSpace(runtime.Str("start-cell"))
|
||||
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
|
||||
@@ -311,23 +315,24 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
|
||||
// collapses to its top-left cell; +csv-put pastes from the anchor and
|
||||
// auto-expands, so the range's lower-right bound is irrelevant.
|
||||
//
|
||||
// Standalone enforces "one of --start-cell / --range" via cobra's
|
||||
// MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs
|
||||
// cobra, so without an explicit check the default "A1" silently wins and the
|
||||
// paste lands at A1 instead of failing like the standalone command. Mirror
|
||||
// the standalone contract: when --start-cell is absent, --range is mandatory.
|
||||
// Standalone enforces exactly one of --start-cell / --range via cobra's
|
||||
// flag groups (see PostMount). A +batch-update sub-op never runs cobra, so
|
||||
// without explicit checks the default "A1" silently wins and the paste lands
|
||||
// at A1 instead of failing like the standalone command. Mirror the
|
||||
// standalone contract: double-set is invalid, and when --start-cell is
|
||||
// absent, --range is mandatory.
|
||||
if !runtime.Changed("start-cell") {
|
||||
rng := strings.TrimSpace(runtime.Str("range"))
|
||||
if rng == "" {
|
||||
return nil, common.FlagErrorf("--start-cell or --range is required")
|
||||
return nil, common.ValidationErrorf("--start-cell or --range is required")
|
||||
}
|
||||
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
|
||||
}
|
||||
if anchor == "" {
|
||||
return nil, common.FlagErrorf("--start-cell is required")
|
||||
return nil, common.ValidationErrorf("--start-cell is required")
|
||||
}
|
||||
if _, _, ok := splitCellRef(anchor); !ok {
|
||||
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
|
||||
return nil, common.ValidationErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -398,11 +403,11 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if rangeStr == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
|
||||
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
|
||||
}
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
@@ -461,7 +466,7 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) > sourceSize {
|
||||
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
return nil, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
}
|
||||
dv["highlight_colors"] = colors
|
||||
}
|
||||
@@ -483,9 +488,9 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
|
||||
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
|
||||
switch {
|
||||
case optsRaw != "" && sourceRange != "":
|
||||
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
|
||||
return 0, nil, common.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one")
|
||||
case optsRaw == "" && sourceRange == "":
|
||||
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
|
||||
return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
|
||||
case optsRaw != "":
|
||||
options, err := requireJSONArray(runtime, "options")
|
||||
if err != nil {
|
||||
@@ -498,7 +503,7 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
|
||||
default: // sourceRange != ""
|
||||
rows, cols, err := rangeDimensions(sourceRange)
|
||||
if err != nil {
|
||||
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
|
||||
return 0, nil, common.ValidationErrorf("--source-range %q: %v", sourceRange, err)
|
||||
}
|
||||
return rows * cols, map[string]interface{}{
|
||||
"type": "listFromRange",
|
||||
@@ -523,7 +528,7 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
if len(colors) > sourceSize {
|
||||
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
return 0, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
}
|
||||
}
|
||||
return sourceSize, nil
|
||||
@@ -696,18 +701,18 @@ var CellsSetImage = common.Shortcut{
|
||||
}
|
||||
r := strings.TrimSpace(runtime.Str("range"))
|
||||
if r == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
return common.ValidationErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(r)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--range %q: %v", r, err)
|
||||
return common.ValidationErrorf("--range %q: %v", r, err)
|
||||
}
|
||||
if rows != 1 || cols != 1 {
|
||||
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
|
||||
return common.ValidationErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
|
||||
}
|
||||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||||
if imgPath == "" {
|
||||
return common.FlagErrorf("--image is required")
|
||||
return common.ValidationErrorf("--image is required")
|
||||
}
|
||||
// Validate path safety here (not just at Execute) so --dry-run also
|
||||
// rejects unsafe paths instead of giving a false-positive preview.
|
||||
@@ -715,7 +720,9 @@ var CellsSetImage = common.Shortcut{
|
||||
// not existence, so legitimate relative paths still dry-run cleanly;
|
||||
// the Execute-time Stat below still reports a missing/unreadable file.
|
||||
if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -771,16 +778,18 @@ var CellsSetImage = common.Shortcut{
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(imgPath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
imgFile, err := runtime.FileIO().Open(imgPath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
imgCfg, _, err := image.DecodeConfig(imgFile)
|
||||
imgFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image dimensions: %w", err)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "decode image dimensions: %s", err).
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
}
|
||||
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: imgPath,
|
||||
@@ -809,7 +818,7 @@ var CellsSetImage = common.Shortcut{
|
||||
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
|
||||
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
|
||||
return wrapCellsSetImageWriteError(err, fileToken)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
@@ -822,3 +831,18 @@ var CellsSetImage = common.Shortcut{
|
||||
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
|
||||
},
|
||||
}
|
||||
|
||||
func wrapCellsSetImageWriteError(err error, fileToken string) error {
|
||||
hint := fmt.Sprintf("image was uploaded as file_token=%s; retry only the cell write with that token or remove the uploaded media", fileToken)
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint += "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "image uploaded (file_token=%s) but cell write failed: %s", fileToken, err).
|
||||
WithHint(hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -42,7 +42,7 @@ func toolInvokePath(token string, kind ToolKind) string {
|
||||
func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode tool input: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "encode tool input: %v", err).WithCause(err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": toolName,
|
||||
@@ -77,13 +77,14 @@ func callTool(
|
||||
|
||||
envelope, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.Errorf(output.ExitAPI, "tool_response",
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"tool %q: unexpected non-JSON-object response: %v", toolName, raw)
|
||||
}
|
||||
code, _ := util.ToFloat64(envelope["code"])
|
||||
if code != 0 {
|
||||
msg, _ := envelope["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"])
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
|
||||
WithCode(int(code))
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
rawOutput, _ := data["output"].(string)
|
||||
@@ -93,8 +94,8 @@ func callTool(
|
||||
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(rawOutput), &out); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "tool_output",
|
||||
"tool %q returned invalid JSON output: %v", toolName, err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"tool %q returned invalid JSON output: %v", toolName, err).WithCause(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ type presentationRef struct {
|
||||
func parsePresentationRef(input string) (presentationRef, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
|
||||
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--presentation cannot be empty").WithParam("--presentation")
|
||||
}
|
||||
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
|
||||
// appear as a prefix of the URL path. Substring matching previously let
|
||||
@@ -38,7 +38,7 @@ func parsePresentationRef(input string) (presentationRef, error) {
|
||||
if strings.Contains(raw, "://") {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Path == "" {
|
||||
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
|
||||
}
|
||||
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
|
||||
return presentationRef{Kind: "slides", Token: token}, nil
|
||||
@@ -46,13 +46,13 @@ func parsePresentationRef(input string) (presentationRef, error) {
|
||||
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
|
||||
return presentationRef{Kind: "wiki", Token: token}, nil
|
||||
}
|
||||
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
|
||||
}
|
||||
// Non-URL input must be a bare token — anything with path/query/fragment
|
||||
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
|
||||
// get silently accepted.
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
|
||||
}
|
||||
return presentationRef{Kind: "slides", Token: raw}, nil
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
|
||||
case "slides":
|
||||
return ref.Token, nil
|
||||
case "wiki":
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": ref.Token},
|
||||
@@ -95,14 +95,14 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType != "slides" {
|
||||
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slides shortcuts require a slides presentation", objType).WithParam("--presentation")
|
||||
}
|
||||
return objToken, nil
|
||||
default:
|
||||
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported presentation ref kind %q", ref.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ var xmlIdAttrRegex = regexp.MustCompile(`(?s)(?:^|\s)id\s*=\s*(["'])(.*?)(["'])`
|
||||
func ensureXMLRootID(xmlFragment, want string) (string, error) {
|
||||
m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("no root element found in XML fragment")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "no root element found in XML fragment")
|
||||
}
|
||||
prefix := xmlFragment[m[2]:m[3]]
|
||||
tagName := xmlFragment[m[4]:m[5]]
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -33,6 +33,9 @@ var SlidesCreate = common.Shortcut{
|
||||
// like wiki_move) so the pre-flight check fails fast and lark-cli's
|
||||
// auth login --scope hint guides the user, instead of leaving an orphaned
|
||||
// empty presentation when the in-flight upload 403s.
|
||||
// NB: no drive scope here on purpose — slides creation never touches drive;
|
||||
// the presentation URL is built locally (see Execute), so we don't gate a
|
||||
// drive-free operation behind a drive scope.
|
||||
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "presentation title"},
|
||||
@@ -42,24 +45,24 @@ var SlidesCreate = common.Shortcut{
|
||||
if slidesStr := runtime.Str("slides"); slidesStr != "" {
|
||||
var slides []string
|
||||
if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil {
|
||||
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides invalid JSON, must be an array of XML strings").WithParam("--slides")
|
||||
}
|
||||
if len(slides) > maxSlidesPerCreate {
|
||||
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate).WithParam("--slides")
|
||||
}
|
||||
// Validate placeholder paths up front so we don't create a presentation
|
||||
// only to fail mid-way on a missing local file.
|
||||
for _, path := range extractImagePlaceholderPaths(slides) {
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
|
||||
return slidesInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return common.FlagErrorf("--slides @%s: must be a regular file", path)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides @%s: must be a regular file", path).WithParam("--slides")
|
||||
}
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
|
||||
path, common.FormatSize(stat.Size()))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
|
||||
path, common.FormatSize(stat.Size())).WithParam("--slides")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +128,7 @@ var SlidesCreate = common.Shortcut{
|
||||
slidesStr := runtime.Str("slides")
|
||||
|
||||
// Step 1: Create presentation
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
@@ -141,7 +144,7 @@ var SlidesCreate = common.Shortcut{
|
||||
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
@@ -165,9 +168,7 @@ var SlidesCreate = common.Shortcut{
|
||||
if len(placeholders) > 0 {
|
||||
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
|
||||
err, presentationID, uploaded)
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("presentation %s was created; %d image(s) uploaded before failure", presentationID, uploaded))
|
||||
}
|
||||
for i := range slides {
|
||||
slides[i] = replaceImagePlaceholders(slides[i], tokens)
|
||||
@@ -182,7 +183,7 @@ var SlidesCreate = common.Shortcut{
|
||||
|
||||
var slideIDs []string
|
||||
for i, slideXML := range slides {
|
||||
slideData, err := runtime.CallAPI(
|
||||
slideData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": -1},
|
||||
@@ -191,9 +192,7 @@ var SlidesCreate = common.Shortcut{
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"slide %d/%d failed: %v (presentation %s was created; %d slide(s) added before failure)",
|
||||
i+1, len(slides), err, presentationID, i)
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
|
||||
}
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
@@ -205,29 +204,14 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch presentation URL via drive meta (best-effort)
|
||||
if metaData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": presentationID,
|
||||
"doc_type": "slides",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
},
|
||||
); err == nil {
|
||||
metas := common.GetSlice(metaData, "metas")
|
||||
if len(metas) > 0 {
|
||||
if meta, ok := metas[0].(map[string]interface{}); ok {
|
||||
if url := common.GetString(meta, "url"); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
|
||||
@@ -268,10 +252,10 @@ func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID str
|
||||
for i, path := range paths {
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
|
||||
return tokens, i, slidesInputStatError(err, fmt.Sprintf("@%s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
|
||||
return tokens, i, errs.NewValidationError(errs.SubtypeInvalidArgument, "@%s: must be a regular file", path).WithParam("--slides")
|
||||
}
|
||||
fileName := filepath.Base(path)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
|
||||
@@ -279,7 +263,7 @@ func uploadSlidesPlaceholders(runtime *common.RuntimeContext, presentationID str
|
||||
|
||||
token, err := uploadSlidesMedia(runtime, path, fileName, stat.Size(), presentationID)
|
||||
if err != nil {
|
||||
return tokens, i, fmt.Errorf("@%s: %w", path, err)
|
||||
return tokens, i, fmt.Errorf("@%s: %w", path, err) //nolint:forbidigo // intermediate; preserves typed cause via %w, reclassified by appendSlidesProgressHint at the call site
|
||||
}
|
||||
tokens[path] = token
|
||||
}
|
||||
|
||||
@@ -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/httpmock"
|
||||
@@ -35,7 +36,6 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
@@ -53,8 +53,10 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -78,7 +80,6 @@ func TestSlidesCreateBotAutoGrant(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/permissions/pres_bot/members",
|
||||
@@ -131,7 +132,6 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
@@ -168,7 +168,6 @@ func TestSlidesCreateBotAutoGrantFailed(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail")
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -238,7 +237,6 @@ func TestSlidesCreateDefaultTitle(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
@@ -301,7 +299,6 @@ func TestSlidesCreateWithSlides(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
|
||||
@@ -404,15 +401,21 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if !strings.Contains(errMsg, "pres_partial") {
|
||||
t.Fatalf("error should contain presentation ID, got: %s", errMsg)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(errMsg, "slide 2/2") {
|
||||
t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg)
|
||||
// The presentation was created but a slide add failed; the recovery hint
|
||||
// carries the partial-progress context (which presentation exists, how many
|
||||
// slides landed) so the caller can resume without recreating.
|
||||
if !strings.Contains(p.Hint, "pres_partial") {
|
||||
t.Fatalf("hint should contain presentation ID, got: %s", p.Hint)
|
||||
}
|
||||
if !strings.Contains(errMsg, "1 slide(s) added") {
|
||||
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
|
||||
if !strings.Contains(p.Hint, "slide 2/2") {
|
||||
t.Fatalf("hint should indicate slide 2/2 failed, got: %s", p.Hint)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "1 slide(s) added") {
|
||||
t.Fatalf("hint should report 1 slide added before failure, got: %s", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +481,6 @@ func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
@@ -551,7 +553,6 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides")
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
@@ -580,8 +581,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
|
||||
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -592,24 +597,15 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_no_url",
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
// batch_query returns an error — URL fetch should be silently skipped
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99999,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "No URL",
|
||||
"--title", "Local URL",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -617,11 +613,11 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_no_url" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
|
||||
if data["xml_presentation_id"] != "pres_local_url" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_local_url", data["xml_presentation_id"])
|
||||
}
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("did not expect url when batch_query fails")
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_local_url" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_local_url", data["url"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,22 +668,6 @@ func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buf
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL.
|
||||
func registerBatchQueryStub(reg *httpmock.Registry, token, url string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": token, "doc_type": "slides", "title": "", "url": url},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// decodeSlidesCreateEnvelope parses the JSON output and returns the data map.
|
||||
func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
@@ -758,7 +738,6 @@ func TestSlidesCreateWithImagePlaceholders(t *testing.T) {
|
||||
}
|
||||
reg.Register(slideStub1)
|
||||
reg.Register(slideStub2)
|
||||
registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img")
|
||||
|
||||
slidesJSON := `[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><img src=\"@a.png\" topLeftX=\"10\"/><img src=\"@b.png\" topLeftX=\"20\"/></data></slide>",
|
||||
|
||||
48
shortcuts/slides/slides_errors.go
Normal file
48
shortcuts/slides/slides_errors.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// slidesInputStatError maps a FileIO.Stat error for an input image path to a
|
||||
// typed validation error, prefixing the caller's context message. Both path
|
||||
// validation failures and other stat errors are user-actionable input problems
|
||||
// (exit code 2). Already-typed errors are not expected here (Stat returns raw
|
||||
// fs errors), so this always classifies as validation.
|
||||
func slidesInputStatError(err error, msg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: unsafe file path: %s", msg, err).WithCause(err)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
|
||||
}
|
||||
|
||||
// appendSlidesProgressHint preserves err's typed classification (per
|
||||
// ERROR_CONTRACT.md "propagate typed errors unchanged") and appends an
|
||||
// orchestration-progress hint — e.g. "presentation was created; N image(s)
|
||||
// uploaded before failure" — so a failure mid-sequence still tells the caller
|
||||
// what partial state exists. An unclassified error (e.g. surfaced from a shared
|
||||
// helper boundary before it can be classified) falls back to a typed internal
|
||||
// error carrying the hint.
|
||||
func appendSlidesProgressHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Hint != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -86,15 +86,15 @@ var SlidesMediaUpload = common.Shortcut{
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
return slidesInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
filepath.Base(filePath), common.FormatSize(stat.Size()))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
filepath.Base(filePath), common.FormatSize(stat.Size())).WithParam("--file")
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
@@ -124,7 +124,7 @@ var SlidesMediaUpload = common.Shortcut{
|
||||
// because the multipart upload API does not accept parent_type=slide_file.
|
||||
func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) {
|
||||
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
fileName, common.FormatSize(fileSize))
|
||||
}
|
||||
parent := presentationID
|
||||
|
||||
@@ -6,11 +6,10 @@ package slides
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
@@ -58,7 +57,7 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
|
||||
return common.FlagErrorf("--slide-id cannot be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty")
|
||||
}
|
||||
parts, err := parseReplaceParts(runtime.Str("parts"))
|
||||
if err != nil {
|
||||
@@ -153,7 +152,7 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
data, err := runtime.CallAPI("POST", url, query, body)
|
||||
data, err := runtime.CallAPITyped("POST", url, query, body)
|
||||
if err != nil {
|
||||
return enrichSlidesReplaceError(err)
|
||||
}
|
||||
@@ -201,11 +200,11 @@ type replacePart struct {
|
||||
func parseReplaceParts(raw string) ([]replacePart, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, common.FlagErrorf("--parts cannot be empty")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts cannot be empty")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, common.FlagErrorf("--parts invalid JSON, must be an array of objects: %v", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts invalid JSON, must be an array of objects: %v", err)
|
||||
}
|
||||
out := make([]replacePart, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
@@ -213,35 +212,35 @@ func parseReplaceParts(raw string) ([]replacePart, error) {
|
||||
if v, ok := m["action"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--parts[%d].action must be a string", i)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action must be a string", i)
|
||||
}
|
||||
p.Action = s
|
||||
}
|
||||
if v, ok := m["replacement"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--parts[%d].replacement must be a string", i)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement must be a string", i)
|
||||
}
|
||||
p.Replacement = &s
|
||||
}
|
||||
if v, ok := m["block_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--parts[%d].block_id must be a string", i)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].block_id must be a string", i)
|
||||
}
|
||||
p.BlockID = &s
|
||||
}
|
||||
if v, ok := m["insertion"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--parts[%d].insertion must be a string", i)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion must be a string", i)
|
||||
}
|
||||
p.Insertion = &s
|
||||
}
|
||||
if v, ok := m["insert_before_block_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--parts[%d].insert_before_block_id must be a string", i)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insert_before_block_id must be a string", i)
|
||||
}
|
||||
p.InsertBeforeBlockID = &s
|
||||
}
|
||||
@@ -261,17 +260,18 @@ const slides3350001Hint = "common causes: (1) block_id not found in current slid
|
||||
// enrichSlidesReplaceError attaches slides3350001Hint when the API returns
|
||||
// 3350001 (invalid param). Other error codes pass through untouched.
|
||||
func enrichSlidesReplaceError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != larkCodeSlidesInvalidParam {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != larkCodeSlidesInvalidParam {
|
||||
return err
|
||||
}
|
||||
// Only fall back to the generic checklist when no upstream hint is
|
||||
// already attached — don't clobber a more specific hint set by the
|
||||
// backend or an earlier wrapper.
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = slides3350001Hint
|
||||
// backend or an earlier wrapper. p points at the embedded Problem, so
|
||||
// the mutation is reflected in the returned err.
|
||||
if p.Hint == "" {
|
||||
p.Hint = slides3350001Hint
|
||||
}
|
||||
return exitErr
|
||||
return err
|
||||
}
|
||||
|
||||
// validateReplaceParts enforces CLI-level invariants:
|
||||
@@ -280,33 +280,33 @@ func enrichSlidesReplaceError(err error) error {
|
||||
// - per-action required fields are present
|
||||
func validateReplaceParts(parts []replacePart) error {
|
||||
if len(parts) == 0 {
|
||||
return common.FlagErrorf("--parts must contain at least 1 item")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item")
|
||||
}
|
||||
if len(parts) > maxReplaceParts {
|
||||
return common.FlagErrorf("--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts)
|
||||
}
|
||||
for i, p := range parts {
|
||||
switch p.Action {
|
||||
case "block_replace":
|
||||
if p.BlockID == nil || strings.TrimSpace(*p.BlockID) == "" {
|
||||
return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty block_id", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty block_id", i)
|
||||
}
|
||||
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
|
||||
return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty replacement", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i)
|
||||
}
|
||||
case "block_insert":
|
||||
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
|
||||
return common.FlagErrorf("--parts[%d] (block_insert) requires non-empty insertion", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i)
|
||||
}
|
||||
case "str_replace":
|
||||
// Backend still accepts str_replace, but product decision is to
|
||||
// force structural edits through the CLI. Block it up-front so
|
||||
// users don't build tooling around an option we won't keep.
|
||||
return common.FlagErrorf("--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action)
|
||||
case "":
|
||||
return common.FlagErrorf("--parts[%d].action is required", i)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action is required", i)
|
||||
default:
|
||||
return common.FlagErrorf("--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -327,7 +327,7 @@ func injectBlockReplaceIDs(parts []replacePart) ([]map[string]interface{}, error
|
||||
case "block_replace":
|
||||
fixed, err := ensureXMLRootID(*p.Replacement, *p.BlockID)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("--parts[%d].replacement: %v", i, err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithCause(err)
|
||||
}
|
||||
fixed = ensureShapeHasContent(fixed)
|
||||
m["block_id"] = *p.BlockID
|
||||
|
||||
@@ -5,14 +5,13 @@ package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
|
||||
@@ -631,15 +630,15 @@ func TestReplaceSlide3350001ErrorEnrichment(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 3350001")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %v", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Code != 3350001 {
|
||||
t.Fatalf("expected code 3350001, got %d", exitErr.Detail.Code)
|
||||
if p.Code != 3350001 {
|
||||
t.Fatalf("expected code 3350001, got %d", p.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
if !strings.Contains(p.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -670,17 +669,17 @@ func TestReplaceSlideNon3350001ErrorNotEnriched(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %v", err)
|
||||
}
|
||||
if exitErr.Detail.Code != 99991672 {
|
||||
t.Fatalf("expected code 99991672, got %d", exitErr.Detail.Code)
|
||||
if p.Code != 99991672 {
|
||||
t.Fatalf("expected code 99991672, got %d", p.Code)
|
||||
}
|
||||
// Non-3350001 errors must not have the slides-specific hint attached.
|
||||
// Assert the actual hint is not our 3350001 checklist, rather than a
|
||||
// string the hint never emits.
|
||||
if strings.Contains(exitErr.Detail.Hint, "common causes") {
|
||||
t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", exitErr.Detail.Hint)
|
||||
if strings.Contains(p.Hint, "common causes") {
|
||||
t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -95,7 +94,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string {
|
||||
}
|
||||
|
||||
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
|
||||
// translate from runtime.CallAPI responses or test fakes.
|
||||
// translate from runtime.CallAPITyped responses or test fakes.
|
||||
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
|
||||
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
|
||||
@@ -103,7 +102,7 @@ type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTas
|
||||
// "simple_task_result" for delete-node).
|
||||
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, resultKey)
|
||||
@@ -167,7 +166,7 @@ func pollWikiAsyncTask(
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
|
||||
@@ -178,29 +177,18 @@ func pollWikiAsyncTask(
|
||||
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
label, taskID, nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
}
|
||||
// ErrWithHint rebuilds the error and drops the upstream Lark
|
||||
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
|
||||
// ExitError by hand so the original API code survives a fully
|
||||
// failed poll, matching wrapWikiNodeDeleteAPIError.
|
||||
return lastStatus, false, &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
// The poll error comes from a typed CallAPITyped path; append the resume
|
||||
// hint in place so the original category / subtype / code / log_id
|
||||
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
|
||||
// errors unchanged"), matching wrapWikiNodeDeleteAPIError.
|
||||
if p, ok := errs.ProblemOf(lastErr); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
hint = p.Hint + "\n" + hint
|
||||
}
|
||||
p.Hint = hint
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
|
||||
@@ -98,16 +98,13 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false when every poll failed")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") ||
|
||||
!strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") {
|
||||
t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
|
||||
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
|
||||
@@ -118,15 +115,10 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
upstream := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991663,
|
||||
Message: "permission denied",
|
||||
Hint: "grant the wiki:node:retrieve scope",
|
||||
},
|
||||
}
|
||||
// The upstream poll error is a typed error carrying its own hint, mirroring
|
||||
// what runtime.CallAPITyped produces for a permission failure.
|
||||
upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied").
|
||||
WithHint("grant the wiki:node:retrieve scope")
|
||||
_, _, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
@@ -134,23 +126,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
|
||||
}
|
||||
// The upstream hint must lead so the actionable cause is read first, with
|
||||
// the resume guidance appended. Type and exit code propagate from upstream.
|
||||
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
|
||||
// the resume guidance appended. The original typed error propagates in place.
|
||||
if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") {
|
||||
t.Fatalf("hint = %q, want upstream hint prepended", p.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", p.Hint)
|
||||
}
|
||||
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype)
|
||||
}
|
||||
if exitErr.Detail.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
|
||||
if p.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
|
||||
nil,
|
||||
@@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (
|
||||
}
|
||||
|
||||
func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_space"},
|
||||
@@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec
|
||||
|
||||
func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error {
|
||||
if spec.SpaceID == "" {
|
||||
return output.ErrValidation("--space-id is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
}
|
||||
return validateOptionalResourceName(spec.SpaceID, "--space-id")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package wiki
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -14,11 +13,12 @@ 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/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -266,19 +266,18 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiDeleteSpacePoll(t)
|
||||
|
||||
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
|
||||
// Seed an error that carries an upstream Lark Detail.Code so the test
|
||||
// Seed a typed error that carries an upstream Lark code and hint so the test
|
||||
// pins that structured fields survive a fully failed poll (not just the
|
||||
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
|
||||
// hint): the poll-exhaustion path must propagate the typed error in place.
|
||||
seeded := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(131006), "msg": "poll failed"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
if p, ok := errs.ProblemOf(seeded); ok {
|
||||
p.Hint = "retry original"
|
||||
}
|
||||
client := &fakeWikiDeleteSpaceClient{
|
||||
taskErrs: []error{&output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 131006,
|
||||
Message: "poll failed",
|
||||
Hint: "retry original",
|
||||
},
|
||||
}},
|
||||
taskErrs: []error{seeded},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
|
||||
}
|
||||
if exitErr.Detail.Code != 131006 {
|
||||
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
|
||||
if p.Code != 131006 {
|
||||
t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -65,7 +65,7 @@ var WikiMemberAdd = common.Shortcut{
|
||||
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
|
||||
|
||||
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
|
||||
data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody())
|
||||
data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,16 +131,16 @@ func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, e
|
||||
return wikiMemberAddSpec{}, err
|
||||
}
|
||||
if spec.MemberID == "" {
|
||||
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
|
||||
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
}
|
||||
// The space-member API rejects opendepartmentid grants under a
|
||||
// tenant_access_token; surface that as a CLI validation error so callers do
|
||||
// not waste a network round-trip on a server-side 403. The escape hatch is
|
||||
// --as user, which is the only identity the API accepts for departments.
|
||||
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
|
||||
return wikiMemberAddSpec{}, output.ErrValidation(
|
||||
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
|
||||
)
|
||||
).WithParam("--member-type")
|
||||
}
|
||||
// --member-type / --member-role enum membership is enforced by the
|
||||
// framework's validateEnumFlags (runner.go) before Validate runs, so no
|
||||
|
||||
@@ -6,7 +6,7 @@ package wiki
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -27,10 +27,10 @@ var wikiMemberRoles = []string{"admin", "member"}
|
||||
// tenant_access_token; same contract as +node-list / +node-create)
|
||||
func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error {
|
||||
if spaceID == "" {
|
||||
return output.ErrValidation("--space-id is required and cannot be blank")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id")
|
||||
}
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
return validateOptionalResourceName(spaceID, "--space-id")
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", apiPath, params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -68,7 +68,7 @@ var WikiMemberRemove = common.Shortcut{
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(spec.MemberID),
|
||||
)
|
||||
data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody())
|
||||
data, err := runtime.CallAPITyped("DELETE", path, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveS
|
||||
return wikiMemberRemoveSpec{}, err
|
||||
}
|
||||
if spec.MemberID == "" {
|
||||
return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
|
||||
return wikiMemberRemoveSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
}
|
||||
// Enum membership for --member-type / --member-role is enforced by the
|
||||
// framework's validateEnumFlags (runner.go) before Validate runs.
|
||||
|
||||
@@ -5,13 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -65,7 +64,7 @@ var WikiMove = common.Shortcut{
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of letting the API return a confusing error.
|
||||
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`").WithParam("--target-space-id")
|
||||
}
|
||||
return validateWikiMoveSpec(spec)
|
||||
},
|
||||
@@ -230,7 +229,7 @@ type wikiMoveAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": token},
|
||||
@@ -243,7 +242,7 @@ func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeReco
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
|
||||
@@ -260,7 +259,7 @@ func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
|
||||
@@ -281,7 +280,7 @@ func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string,
|
||||
}
|
||||
|
||||
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
@@ -324,28 +323,28 @@ func validateWikiMoveSpec(spec wikiMoveSpec) error {
|
||||
|
||||
if spec.NodeToken != "" {
|
||||
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
|
||||
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token cannot be combined with --obj-type, --obj-token, or --apply").WithParam("--node-token")
|
||||
}
|
||||
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-parent-token and --target-space-id cannot both be empty for wiki node move").WithParam("--target-space-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if spec.SourceSpaceID != "" {
|
||||
return output.ErrValidation("--source-space-id can only be used with --node-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--source-space-id can only be used with --node-token").WithParam("--source-space-id")
|
||||
}
|
||||
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
|
||||
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move").WithParam("--node-token")
|
||||
}
|
||||
if spec.ObjType == "" {
|
||||
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for docs-to-wiki move").WithParam("--obj-type")
|
||||
}
|
||||
if spec.ObjToken == "" {
|
||||
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-token is required for docs-to-wiki move").WithParam("--obj-token")
|
||||
}
|
||||
if spec.TargetSpaceID == "" {
|
||||
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id is required for docs-to-wiki move").WithParam("--target-space-id")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -426,7 +425,7 @@ func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.Run
|
||||
case wikiMoveModeDocsToWiki:
|
||||
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
|
||||
default:
|
||||
return nil, output.ErrValidation("unknown wiki move mode")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "unknown wiki move mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,11 +478,11 @@ func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec
|
||||
if targetSpaceID == "" {
|
||||
targetSpaceID = parentSpaceID
|
||||
} else if targetSpaceID != parentSpaceID {
|
||||
return "", "", output.ErrValidation(
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--target-space-id %q does not match target parent node space %q",
|
||||
spec.TargetSpaceID,
|
||||
parentSpaceID,
|
||||
)
|
||||
).WithParam("--target-space-id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +548,7 @@ func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +591,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki move task failed: %s", status.PrimaryStatusLabel())
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
|
||||
@@ -605,14 +604,18 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
|
||||
taskID,
|
||||
nextCommand,
|
||||
)
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
|
||||
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
|
||||
hint = exitErr.Detail.Hint + "\n" + hint
|
||||
// The poll error comes from a typed CallAPITyped path; append the resume
|
||||
// hint in place so the original category / subtype / code / log_id
|
||||
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
|
||||
// errors unchanged").
|
||||
if p, ok := errs.ProblemOf(lastErr); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
hint = p.Hint + "\n" + hint
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
|
||||
p.Hint = hint
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
@@ -620,7 +623,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
|
||||
|
||||
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return wikiMoveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskStatus{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,11 +14,11 @@ 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/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -837,7 +836,7 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
|
||||
|
||||
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
|
||||
client := &fakeWikiMoveClient{
|
||||
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
|
||||
taskErrs: []error{errs.NewAPIError(errs.SubtypeServerError, "poll failed").WithHint("retry original")},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -850,12 +849,12 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
|
||||
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -44,10 +44,10 @@ var WikiNodeCopy = common.Shortcut{
|
||||
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
|
||||
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
|
||||
if targetSpaceID == "" && targetParent == "" {
|
||||
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --target-space-id or --target-parent-node-token is required").WithParam("--target-space-id")
|
||||
}
|
||||
if targetSpaceID != "" && targetParent != "" {
|
||||
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id and --target-parent-node-token are mutually exclusive; provide only one").WithParam("--target-space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
|
||||
return err
|
||||
@@ -72,7 +72,7 @@ var WikiNodeCopy = common.Shortcut{
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
|
||||
common.MaskToken(nodeToken), common.MaskToken(spaceID))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
|
||||
validate.EncodePathSegment(spaceID),
|
||||
validate.EncodePathSegment(nodeToken)),
|
||||
|
||||
@@ -5,12 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -170,7 +170,7 @@ type wikiNodeCreateAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": token},
|
||||
@@ -183,7 +183,7 @@ func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNo
|
||||
}
|
||||
|
||||
func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
|
||||
nil,
|
||||
@@ -196,7 +196,7 @@ func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wik
|
||||
}
|
||||
|
||||
func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)),
|
||||
nil,
|
||||
@@ -231,22 +231,22 @@ func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity)
|
||||
}
|
||||
|
||||
if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" {
|
||||
return output.ErrValidation("--origin-node-token is required when --node-type=shortcut")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token is required when --node-type=shortcut").WithParam("--origin-node-token")
|
||||
}
|
||||
if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" {
|
||||
return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token can only be used when --node-type=shortcut").WithParam("--origin-node-token")
|
||||
}
|
||||
|
||||
// Bot identity has no meaningful "personal document library" target, so
|
||||
// my_library must be rejected explicitly instead of deferring to API-time
|
||||
// resolution errors.
|
||||
if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token").WithParam("--space-id")
|
||||
}
|
||||
// Bot identity also cannot fall back implicitly, so it requires an explicit
|
||||
// target or a parent it can resolve from.
|
||||
if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" {
|
||||
return output.ErrValidation("bot identity requires --space-id or --parent-node-token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").WithParam("--space-id")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -334,7 +334,7 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
|
||||
return nil, wrapWikiNodeCreateRetryError(lastErr)
|
||||
}
|
||||
if node == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node create returned no node")
|
||||
}
|
||||
|
||||
return &wikiNodeCreateExecution{
|
||||
@@ -346,45 +346,32 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
|
||||
// isWikiNodeLockContention returns true if the error is a Lark API error with
|
||||
// code 131009 (wiki node lock contention), which is retryable with backoff.
|
||||
func isWikiNodeLockContention(err error) bool {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return exitErr.Detail.Code == output.LarkErrWikiLockContention
|
||||
return p.Code == output.LarkErrWikiLockContention
|
||||
}
|
||||
|
||||
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
|
||||
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
|
||||
// the original Lark error code survives in the envelope.
|
||||
// API error in place, preserving its typed category / subtype / code / log_id.
|
||||
func wrapWikiNodeCreateRetryError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
hint := fmt.Sprintf(
|
||||
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
|
||||
wikiNodeCreateMaxRetries,
|
||||
)
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
if existing := strings.TrimSpace(p.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: exitErr.Err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
p.Hint = hint
|
||||
return err
|
||||
}
|
||||
|
||||
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
|
||||
@@ -397,7 +384,7 @@ func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient
|
||||
return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken)
|
||||
}
|
||||
if identity.IsBot() {
|
||||
return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token")
|
||||
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").WithParam("--space-id")
|
||||
}
|
||||
return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client)
|
||||
}
|
||||
@@ -434,12 +421,12 @@ func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wik
|
||||
return wikiResolvedSpace{}, err
|
||||
}
|
||||
if parentSpaceID != resolved.SpaceID {
|
||||
return wikiResolvedSpace{}, output.ErrValidation(
|
||||
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id %q does not match parent node space %q (resolved space: %q)",
|
||||
spec.SpaceID,
|
||||
parentSpaceID,
|
||||
resolved.SpaceID,
|
||||
)
|
||||
).WithParam("--space-id")
|
||||
}
|
||||
|
||||
resolved.ParentNode = parent
|
||||
@@ -483,21 +470,21 @@ func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) {
|
||||
if node != nil && node.SpaceID != "" {
|
||||
return node.SpaceID, nil
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node lookup returned no space_id")
|
||||
}
|
||||
|
||||
func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
|
||||
if space != nil && space.SpaceID != "" {
|
||||
return space.SpaceID, nil
|
||||
}
|
||||
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "personal document library was not found, please specify --space-id").WithParam("--space-id")
|
||||
}
|
||||
|
||||
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
|
||||
// the per-user real space_id. Shared by shortcuts that accept the my_library
|
||||
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
|
||||
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
|
||||
nil, nil,
|
||||
@@ -517,14 +504,14 @@ func validateOptionalResourceName(value, flagName string) error {
|
||||
return nil
|
||||
}
|
||||
if err := validate.ResourceName(value, flagName); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
|
||||
if node == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response missing node")
|
||||
}
|
||||
return &wikiNodeRecord{
|
||||
SpaceID: common.GetString(node, "space_id"),
|
||||
@@ -542,7 +529,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
|
||||
|
||||
func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) {
|
||||
if space == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space response missing space")
|
||||
}
|
||||
return &wikiSpaceRecord{
|
||||
SpaceID: common.GetString(space, "space_id"),
|
||||
|
||||
@@ -16,13 +16,26 @@ 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/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wikiTestLockContentionErr builds the typed API error that runtime.CallAPITyped
|
||||
// produces for a wiki write-lock-contention response (code 131009), so the
|
||||
// retry path's errs.ProblemOf(...).Code check sees the same shape in tests as
|
||||
// in production.
|
||||
func wikiTestLockContentionErr() error {
|
||||
return errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(output.LarkErrWikiLockContention), "msg": "lock contention"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
}
|
||||
|
||||
type fakeWikiNodeCreateCall struct {
|
||||
SpaceID string
|
||||
Spec wikiNodeCreateSpec
|
||||
@@ -785,7 +798,7 @@ func TestWikiNodeURL(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -831,7 +844,7 @@ func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -853,25 +866,30 @@ func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
|
||||
if len(client.createInvoked) != 3 {
|
||||
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Code != output.LarkErrWikiLockContention {
|
||||
t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention)
|
||||
if p.Code != output.LarkErrWikiLockContention {
|
||||
t.Fatalf("error code = %d, want %d", p.Code, output.LarkErrWikiLockContention)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "failed after 2 retries") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion message", p.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "lock contention") {
|
||||
t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "lock contention") {
|
||||
t.Fatalf("hint = %q, want original classification hint preserved", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention
|
||||
// A typed API error for a different code (rate limit, not lock contention),
|
||||
// mirroring what runtime.CallAPITyped produces.
|
||||
otherErr := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(output.LarkErrRateLimit), "msg": "rate limit"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -901,7 +919,7 @@ func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
@@ -944,7 +962,7 @@ func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
|
||||
func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
|
||||
lockErr := wikiTestLockContentionErr()
|
||||
|
||||
client := &fakeWikiNodeCreateClient{
|
||||
spaces: map[string]*wikiSpaceRecord{
|
||||
|
||||
@@ -5,14 +5,13 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -135,7 +134,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
|
||||
if objType != "" && objType != "wiki" {
|
||||
params["obj_type"] = objType
|
||||
}
|
||||
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
data, err := api.runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,7 +142,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
fmt.Sprintf(
|
||||
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
|
||||
@@ -160,7 +159,7 @@ func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spe
|
||||
}
|
||||
|
||||
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
|
||||
data, err := api.runtime.CallAPI(
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
|
||||
@@ -188,7 +187,7 @@ func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec,
|
||||
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
|
||||
}
|
||||
|
||||
spec := wikiNodeDeleteSpec{
|
||||
@@ -200,14 +199,14 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
}
|
||||
spec.NodeToken = token
|
||||
spec.SourceKind = "url"
|
||||
@@ -222,32 +221,32 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
|
||||
case spec.ObjType == "":
|
||||
spec.ObjType = inferred
|
||||
case spec.ObjType != inferred:
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, inferred,
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.NodeToken = tokenInput
|
||||
spec.SourceKind = "raw"
|
||||
}
|
||||
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required (one of: %s)",
|
||||
strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
if !isValidWikiDeleteObjType(spec.ObjType) {
|
||||
return wikiNodeDeleteSpec{}, output.ErrValidation(
|
||||
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type %q is not valid; pick one of: %s",
|
||||
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
|
||||
return wikiNodeDeleteSpec{}, err
|
||||
@@ -405,12 +404,12 @@ func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
var hint string
|
||||
switch exitErr.Detail.Code {
|
||||
switch p.Code {
|
||||
case wikiDeleteNodeErrCodeApprovalRequired:
|
||||
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
|
||||
case wikiDeleteNodeErrCodeSubtreeTooLarge:
|
||||
@@ -419,22 +418,11 @@ func wrapWikiNodeDeleteAPIError(err error) error {
|
||||
if hint == "" {
|
||||
return err
|
||||
}
|
||||
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
|
||||
// Append the hint in place so the typed error keeps its category / subtype /
|
||||
// code / log_id (per ERROR_CONTRACT.md "propagate typed errors unchanged").
|
||||
if existing := strings.TrimSpace(p.Hint); existing != "" {
|
||||
hint = existing + "\n" + hint
|
||||
}
|
||||
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
|
||||
// the ExitError by hand so the Lark error code stays available to logs and
|
||||
// downstream pivots.
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: exitErr.Detail.Message,
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
}
|
||||
p.Hint = hint
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -357,63 +357,55 @@ func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeApprovalRequired,
|
||||
Message: "node requires delete approval",
|
||||
},
|
||||
}
|
||||
in := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") {
|
||||
t.Fatalf("hint = %q, want approval guidance", p.Hint)
|
||||
}
|
||||
// Original code/message must be preserved so logs and dashboards still
|
||||
// pivot on the upstream error code.
|
||||
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
|
||||
if p.Code != wikiDeleteNodeErrCodeApprovalRequired {
|
||||
t.Fatalf("hint wrapper lost the original code: %d", p.Code)
|
||||
}
|
||||
if exitErr.Detail.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
|
||||
if p.Message != "node requires delete approval" {
|
||||
t.Fatalf("message changed unexpectedly: %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
|
||||
Message: "subtree too large",
|
||||
},
|
||||
}
|
||||
in := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T %v", got, got)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, "--include-children=false") {
|
||||
t.Fatalf("hint = %q, want subtree-too-large guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
|
||||
}
|
||||
in := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(131005), "msg": "node not found"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
got := wrapWikiNodeDeleteAPIError(in)
|
||||
if !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("unknown code should pass through; got %#v", got)
|
||||
if got != in {
|
||||
t.Fatalf("unknown code should pass through unchanged; got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -98,7 +98,7 @@ var WikiNodeGet = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,10 +109,10 @@ var WikiNodeGet = common.Shortcut{
|
||||
}
|
||||
|
||||
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
|
||||
return output.ErrValidation(
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id %q does not match the resolved node space %q (node_token=%s)",
|
||||
spec.SpaceID, node.SpaceID, node.NodeToken,
|
||||
)
|
||||
).WithParam("--space-id")
|
||||
}
|
||||
if spec.SpaceID != "" && node.SpaceID == "" {
|
||||
// The cross-check was requested but get_node returned no space_id,
|
||||
@@ -178,8 +178,8 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
|
||||
legacy := strings.TrimSpace(legacyToken)
|
||||
switch {
|
||||
case canonical != "" && legacy != "" && canonical != legacy:
|
||||
return "", output.ErrValidation(
|
||||
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)").WithParam("--node-token")
|
||||
case canonical != "":
|
||||
return nodeToken, nil
|
||||
default:
|
||||
@@ -193,7 +193,7 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
|
||||
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
|
||||
tokenInput := strings.TrimSpace(rawToken)
|
||||
if tokenInput == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required")
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
|
||||
}
|
||||
|
||||
spec := wikiNodeGetSpec{
|
||||
@@ -204,14 +204,14 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
if strings.Contains(tokenInput, "://") {
|
||||
u, err := url.Parse(tokenInput)
|
||||
if err != nil || u.Path == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
|
||||
}
|
||||
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
|
||||
if !ok {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
|
||||
u.Path,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
}
|
||||
spec.Token = token
|
||||
if urlObjType == "" {
|
||||
@@ -223,16 +223,16 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
case spec.ObjType == "" && urlObjType != "":
|
||||
spec.ObjType = urlObjType
|
||||
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
|
||||
spec.ObjType, urlObjType,
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else if strings.ContainsAny(tokenInput, "/?#") {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
|
||||
tokenInput,
|
||||
)
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
@@ -241,10 +241,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
@@ -253,10 +253,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, output.ErrValidation(
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
)
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -53,7 +54,7 @@ var WikiNodeList = common.Shortcut{
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
@@ -150,7 +151,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", apiPath, params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -60,14 +60,14 @@ var WikiSpaceCreate = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
|
||||
|
||||
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
data, err := runtime.CallAPITyped("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := common.GetMap(data, "space")
|
||||
if raw == nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space create returned no space")
|
||||
}
|
||||
|
||||
out := wikiSpaceCreateOutput(raw)
|
||||
@@ -100,7 +100,7 @@ func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpe
|
||||
Description: strings.TrimSpace(runtime.Str("description")),
|
||||
}
|
||||
if spec.Name == "" {
|
||||
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
|
||||
return wikiSpaceCreateSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required and cannot be blank").WithParam("--name")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -103,7 +104,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
|
||||
data, err := runtime.CallAPITyped("GET", wikiSpacesAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
@@ -181,10 +182,10 @@ func valueOrDash(v interface{}) string {
|
||||
// +space-list and +node-list.
|
||||
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
|
||||
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
|
||||
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and %d", maxPageSize).WithParam("--page-size")
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return common.FlagErrorf("--page-limit must be a non-negative integer")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,56 +1,35 @@
|
||||
---
|
||||
name: lark-approval
|
||||
version: 1.0.0
|
||||
description: "飞书审批 API:审批实例、审批任务管理。"
|
||||
version: 1.1.0
|
||||
description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task);不负责创建审批定义和发起新审批。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
# approval (v4)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## API Resources
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
|
||||
处理链:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### instances
|
||||
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
- `initiated` — 查询用户的已发起列表
|
||||
|
||||
### tasks
|
||||
|
||||
- `remind` — 催办审批人
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
- `query` — 查询用户的任务列表
|
||||
- `add_sign` — 审批任务加签
|
||||
- `rollback` — 退回审批任务
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `instances.initiated` | `approval:instance:read` |
|
||||
| `tasks.remind` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
| `tasks.query` | `approval:task:read` |
|
||||
| `tasks.add_sign` | `approval:task:write` |
|
||||
| `tasks.rollback` | `approval:task:write` |
|
||||
## 不在本 skill 范围
|
||||
|
||||
创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-calendar
|
||||
version: 1.0.0
|
||||
description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人及预定会议室)、+update(更新既有日程字段,或独立增删参会人/会议室)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)"
|
||||
description: "飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc)、待办任务(走 lark-task)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,93 +10,88 @@ metadata:
|
||||
|
||||
# calendar (v4)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
**CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。**
|
||||
**CRITICAL — 凡涉及【预约日程/会议】或【查询/搜索会议室】,第一步 MUST 强制使用 Read 工具读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
**CRITICAL — 术语约束:用户日常表达中常说的“帮我约个日历”、“查一下今天的日历”等,其实际意图通常是针对 日程(Event) 的创建或查询,而非操作 日历(Calendar) 容器本身。请自动将口语化的“日历”意图映射为“日程”操作(如 `+create`, `+agenda`)。**
|
||||
**CRITICAL — 会议与日程的意图路由:**
|
||||
- **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。
|
||||
- **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排,则属于本技能(lark-calendar)的业务域,请继续使用本技能处理。
|
||||
**CRITICAL — 任务类型分流:处理“预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间”时,必须先判断用户是在“新建日程”还是“编辑已有日程”。**
|
||||
- **编辑已有日程的强信号**:用户明确提到某个已存在的日程锚点(如标题、时间段、`这个日程`、`这场会`)并表达修改动作(如“添加”“移除”“改到”“换会议室”“调整时间”)。这类请求默认走**编辑已有日程**,绝不能直接按新建处理。
|
||||
- **编辑已有日程的前置步骤**:一旦判定为编辑,MUST 先定位目标日程或具体实例的 `event_id`,再继续后续流程。若是重复性日程,MUST 先定位到对应实例的 `event_id`。
|
||||
- **新建日程**:只有当用户表达的是“新约一个会/创建一个日程/安排一次会议”等新增意图,且没有指向某个既有日程的修改动作时,才进入新建流程。
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||
|
||||
**CRITICAL — 验证与同步延迟:在涉及删除日程(delete)、修改日程(patch)或者涉及添加移除参与人/会议室之后,如果需要进行二次查询验证操作结果,MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。**
|
||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
|
||||
**CRITICAL — 重复性日程的实例操作:目前已经完全具备对重复性日程的某个具体实例进行操作的能力(例如:编辑某个实例、删除某个实例、为某个实例添加/删除参与人、为某个实例添加/移除会议室)。只要在对应的操作中传递对应实例的 `event_id` 即可。因此,MUST 先定位到对应的那次实例的 `event_id`(可通过 `events search_event` 搜索日程,或 `+agenda` 查看对应时间范围的日程等相关查询获取),绝对禁止直接使用原重复性日程的 `event_id` 进行操作。**
|
||||
## 身份
|
||||
|
||||
**时间与日期推断规范:**
|
||||
为确保准确性,在涉及时间推断时,请严格遵循以下规则:
|
||||
- **星期的定义**:周一是一周的第一天,周日是一周的最后一天。计算`下周一`等相对日期时,务必基于当前真实日期和星期基准进行推算,避免算错日期。
|
||||
- **一天的范围**:当用户提到`明天`、`今天`等泛指某一天时,时间范围应默认覆盖整天时间范围。**切勿**自行缩减查询范围,以免遗漏晚上的时间安排。
|
||||
- **历史时间约束**:不能预约已经完全过去的时间。唯一的例外情况是“跨越当前时间”的日程,即日程的开始时间在过去,但结束时间在未来。
|
||||
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
|
||||
|
||||
## 核心场景
|
||||
```bash
|
||||
# BAD — bot 身份查用户日程,返回空列表
|
||||
lark-cli calendar +agenda --as bot
|
||||
|
||||
### 1. 预约新日程/会议、编辑已有日程、查询/搜索可用会议室
|
||||
**BLOCKING REQUIREMENT (阻塞性要求): 只要用户的意图包含“预约日程/会议”或“查询/搜索可用会议室”,你必须立即停止其他思考,优先使用 Read 工具完整读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)!未读取该文件前,绝对禁止执行任何日程创建或会议室查询操作。**
|
||||
**CRITICAL: 必须严格按照上述文档中定义的工作流(Workflow)执行后续操作。处理该场景时,默认做“智能助理”,不要做“表单填写机”。能补全的默认值先补全,只有在时间冲突、结果无法唯一确定、时间语义存在歧义时才主动追问。**
|
||||
**CRITICAL: 执行顺序必须固定为:先判断任务类型(新建/编辑);若为编辑先定位目标日程 `event_id`;再补默认值或继承已定位日程的已知信息;再判断时间是否明确;最后进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。**
|
||||
**CRITICAL: 明确时间且需要会议室时,先基于最终确定的时间块执行 `+room-find`,再按需执行 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。如果是编辑已有日程且不改时间,只新增会议室,则必须基于已定位日程的原始时间执行 `+room-find`,且最终落地时默认保留已存在的会议室;只有用户明确表达“更换会议室”或“移除会议室”时,才删除原会议室。**
|
||||
**CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。**
|
||||
**BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。**
|
||||
**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在最终执行创建新日程或更新既有日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。**
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日历(Calendar)**:日程的容器。每个用户有一个主日历(primary calendar),也可以创建或订阅共享日历。
|
||||
- **日程(Event)**:日历中的单个日程,包含起止时间、地点、标题、参与人等属性。支持单次日程和重复日程,遵循RFC5545 iCalendar国际标准。
|
||||
- ***全天日程(All-day Event)***: 只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **日程实例(Instance)**:日程的具体时间实例,本质是对日程的展开。普通日程和例外日程对应1个Instance,重复性日程对应N个Instance。在按时间段查询时,可通过实例视图将重复日程展开为独立的实例返回,以便在时间线上准确展示和管理。
|
||||
- **重复规则(Rrule/Recurrence Rule)**:定义重复性日程的重复规则,比如`FREQ=DAILY;UNTIL=20230307T155959Z;INTERVAL=14`表示每14天重复一次。
|
||||
- **例外日程(Exception)**:重复性日程中与原重复性日程不一致的日程。
|
||||
- **参会人(Attendee)**:日程的参与者,可以是用户、群、会议室资源、外部邮箱地址等。每个参与人有独立的RSVP状态。
|
||||
- **响应状态(RSVP)**:参与人对日程邀请的回复状态(接受/拒绝/待定)。
|
||||
- **忙闲时间(FreeBusy)**:查询用户在指定时间段的忙闲状态,用于会议时间协调。
|
||||
- **会议室(Room)**:“room”不是“房间”,是“会议室”。请在理解和处理意图时将“room”和“房间”准确映射为“会议室”及其相关操作。
|
||||
- **时间块(Time Slot / Time Block)**:指一个**具体且确定**的连续时间段(如 `14:00~15:00`)。在文档中,它与泛指的“时间范围/区间”(如“今天下午”、“下周”)有严格区别。在调用预定、查询可用会议室等确切操作时,必须基于确定的“时间块”而非模糊的“时间范围”。
|
||||
|
||||
## 资源关系
|
||||
|
||||
```
|
||||
Calendar (日历)
|
||||
└── Event (日程)
|
||||
├── Attendee (参会人)
|
||||
└── Reminder (提醒)
|
||||
# GOOD — user 身份查日程
|
||||
lark-cli calendar +agenda --as user
|
||||
```
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion**) |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
|
||||
## 会议室相关规则
|
||||
## 前置条件路由
|
||||
|
||||
- **会议室是日程的一种参与人(resource attendee),不能脱离日程单独存在或单独预定。**
|
||||
- **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。**
|
||||
- `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。
|
||||
- **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。**
|
||||
- **编辑已有日程时,如果用户表达的是“添加会议室/再加一个会议室”,默认语义是增量添加,必须保留已有会议室;只有在用户明确表达“更换会议室”“把原会议室换掉”“移除会议室”时,才执行旧会议室删除。**
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||
|
||||
## 术语映射
|
||||
|
||||
用户日常说的"帮我约个日历""查一下今天的日历",实际意图是针对**日程(Event)**的创建或查询,而非操作日历(Calendar)容器本身。自动将口语化的"日历"意图映射为"日程"操作。
|
||||
|
||||
## 意图路由
|
||||
|
||||
| 用户意图 | 路由到 |
|
||||
|----------|--------|
|
||||
| 查询过去的会议("昨天的会议""上周的会") | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
|
||||
| 查询日历/日程或未来时间的会议 | 本 skill |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
处理"预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间"时,必须先判断新建 vs 编辑:
|
||||
|
||||
- **编辑已有日程的强信号**:用户提到已存在的日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(添加、移除、改到、换会议室、调整时间)。默认走编辑流,绝不能按新建处理。
|
||||
- **新建日程**:用户表达新增意图("新约一个会""创建一个日程""安排一次会议"),且没有指向既有日程的修改动作。
|
||||
|
||||
## 时间推断规范
|
||||
|
||||
- **星期的定义**:周一是一周的第一天,周日是最后一天。计算"下周一"等相对日期时,基于当前真实日期推算。
|
||||
- **一天的范围**:用户提到"明天""今天"等泛指某天时,时间范围应覆盖整天,不要自行缩减。
|
||||
- **历史时间约束**:不能预约已经完全过去的时间。唯一例外是"跨越当前时间"的日程(开始在过去、结束在未来)。
|
||||
|
||||
## 会议室规则
|
||||
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema calendar.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli calendar <resource> <method> [flags] # 调用 API
|
||||
lark-cli calendar <resource> <method> [flags]
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### calendars
|
||||
|
||||
- `create` — 创建共享日历
|
||||
@@ -120,35 +115,18 @@ lark-cli calendar <resource> <method> [flags] # 调用 API
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `search_event` — 搜索日程(注:目前只会返回日程id、日程主题、日程时间的信息,需要更多的日程详情,需要走 `events get` 命令)
|
||||
- `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需走 `events get`)
|
||||
- `share_info` — 获取日程分享链接
|
||||
|
||||
### freebusys
|
||||
|
||||
- `list` — 查询主日历日程忙闲信息
|
||||
|
||||
## 权限表
|
||||
## 不在本 skill 范围
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `calendars.create` | `calendar:calendar:create` |
|
||||
| `calendars.delete` | `calendar:calendar:delete` |
|
||||
| `calendars.get` | `calendar:calendar:read` |
|
||||
| `calendars.list` | `calendar:calendar:read` |
|
||||
| `calendars.patch` | `calendar:calendar:update` |
|
||||
| `calendars.primary` | `calendar:calendar:read` |
|
||||
| `calendars.search` | `calendar:calendar:read` |
|
||||
| `event.attendees.batch_delete` | `calendar:calendar.event:update` |
|
||||
| `event.attendees.create` | `calendar:calendar.event:update` |
|
||||
| `event.attendees.list` | `calendar:calendar.event:read` |
|
||||
| `events.create` | `calendar:calendar.event:create` |
|
||||
| `events.delete` | `calendar:calendar.event:delete` |
|
||||
| `events.get` | `calendar:calendar.event:read` |
|
||||
| `events.instance_view` | `calendar:calendar.event:read` |
|
||||
| `events.patch` | `calendar:calendar.event:update` |
|
||||
| `events.search_event` | `calendar:calendar.event:read` |
|
||||
| `events.share_info` | `calendar:calendar.event:read` |
|
||||
| `freebusys.list` | `calendar:calendar.free_busy:read` |
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user