From 201e3e016f89b71886f307cbc5a0745751d5950d Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:43:20 +0800 Subject: [PATCH 01/48] feat(doc): emit typed error envelopes across the doc domain (#1346) Emit structured validation, API, network, file, and internal error envelopes for Doc shortcuts so users and agents can recover from failed document workflows using stable type, subtype, param, and code fields. Add Doc domain errscontract and golangci guards to prevent legacy envelope and common helper regressions. --- .golangci.yml | 6 +- .../rule_no_legacy_common_helper_call.go | 1 + .../rule_no_legacy_envelope_literal.go | 1 + lint/errscontract/rules_test.go | 18 + shortcuts/doc/clipboard.go | 30 +- shortcuts/doc/doc_errors.go | 34 ++ shortcuts/doc/doc_errors_test.go | 420 ++++++++++++++++++ shortcuts/doc/doc_media_download.go | 14 +- shortcuts/doc/doc_media_insert.go | 103 +++-- shortcuts/doc/doc_media_preview.go | 14 +- shortcuts/doc/doc_media_upload.go | 6 +- shortcuts/doc/docs_create_v2.go | 8 +- shortcuts/doc/docs_fetch_v2.go | 22 +- shortcuts/doc/docs_search.go | 13 +- shortcuts/doc/docs_update_v2.go | 33 +- shortcuts/doc/helpers.go | 16 +- shortcuts/doc/v2_only.go | 14 +- 17 files changed, 630 insertions(+), 123 deletions(-) create mode 100644 shortcuts/doc/doc_errors.go create mode 100644 shortcuts/doc/doc_errors_test.go diff --git a/.golangci.yml b/.golangci.yml index bddedb889..2e52c6669 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index 7a6558200..a1e404ff6 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -21,6 +21,7 @@ var migratedCommonHelperPaths = []string{ "shortcuts/base/", "shortcuts/calendar/", "shortcuts/contact/", + "shortcuts/doc/", "shortcuts/drive/", "shortcuts/event/", "shortcuts/mail/", diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index bfaba9ff6..e75bb3fca 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -22,6 +22,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/base/", "shortcuts/calendar/", "shortcuts/contact/", + "shortcuts/doc/", "shortcuts/drive/", "shortcuts/event/", "shortcuts/mail/", diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 84535a7d7..410bb6f8e 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -950,6 +950,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "HandleApiResult", } paths := []string{ + "shortcuts/doc/docs_fetch_v2.go", "shortcuts/drive/drive_search.go", "shortcuts/mail/mail_send.go", "shortcuts/okr/okr_progress_create.go", @@ -1003,6 +1004,23 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/shortcuts/doc/clipboard.go b/shortcuts/doc/clipboard.go index cb9f2c225..c7b8ed877 100644 --- a/shortcuts/doc/clipboard.go +++ b/shortcuts/doc/clipboard.go @@ -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).WithCause(err) } 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.).") } diff --git a/shortcuts/doc/doc_errors.go b/shortcuts/doc/doc_errors.go new file mode 100644 index 000000000..770351a84 --- /dev/null +++ b/shortcuts/doc/doc_errors.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "errors" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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) +} + +// wrapDocInputFileErr wraps a --file Stat/read failure via the shared typed +// helper (which sets the cause) and tags it with the --file param so agents +// learn which flag to fix. The common helper is flag-agnostic, so the param is +// attached here at the Doc call site rather than mutating shared behavior. +func wrapDocInputFileErr(err error, readMsg string) error { + wrapped := common.WrapInputStatErrorTyped(err, readMsg) + var ve *errs.ValidationError + if errors.As(wrapped, &ve) { + ve.Param = "--file" + } + return wrapped +} diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go new file mode 100644 index 000000000..56871a87f --- /dev/null +++ b/shortcuts/doc/doc_errors_test.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "errors" + "slices" + "strconv" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// testDocxToken is a bare docx token that parseDocumentRef accepts, letting the +// validation tests reach the flag checks that run after --doc is resolved. +const testDocxToken = "doxcnDocErrorsTestToken" + +// docValidateRuntime builds a RuntimeContext carrying only the flags a Doc +// Validate function reads. String values are applied (and marked Changed) only +// when non-empty; int values are always applied so Changed() reports true, +// mirroring how cobra records an explicitly supplied numeric flag. +func docValidateRuntime(t *testing.T, str map[string]string, bools map[string]bool, ints map[string]int) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "docs"} + fs := cmd.Flags() + for name, val := range str { + fs.String(name, "", "") + if val != "" { + if err := fs.Set(name, val); err != nil { + t.Fatalf("set --%s=%q: %v", name, val, err) + } + } + } + for name, val := range bools { + fs.Bool(name, false, "") + if val { + if err := fs.Set(name, "true"); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + } + for name, val := range ints { + fs.Int(name, 0, "") + if err := fs.Set(name, strconv.Itoa(val)); err != nil { + t.Fatalf("set --%s=%d: %v", name, val, err) + } + } + return common.TestNewRuntimeContext(cmd, nil) +} + +// assertValidationContract pins the typed envelope every migrated Doc +// validation fault must emit: a *errs.ValidationError in CategoryValidation +// with the expected Subtype, the single offending flag in Param, and every +// involved flag in Params. Single-flag faults set Param and leave Params empty; +// multi-flag faults (mutual exclusion, "one of A or B") leave Param empty and +// enumerate each flag in Params so agents resolve them without parsing the text. +func assertValidationContract(t *testing.T, err error, wantSubtype errs.Subtype, wantParam string, wantParams ...string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err) + } + if ve.Category != errs.CategoryValidation { + t.Errorf("category = %q, want %q", ve.Category, errs.CategoryValidation) + } + if ve.Subtype != wantSubtype { + t.Errorf("subtype = %q, want %q", ve.Subtype, wantSubtype) + } + if ve.Param != wantParam { + t.Errorf("param = %q, want %q", ve.Param, wantParam) + } + gotParams := make([]string, len(ve.Params)) + for i, p := range ve.Params { + gotParams[i] = p.Name + } + if !slices.Equal(gotParams, wantParams) { + t.Errorf("params = %v, want %v", gotParams, wantParams) + } +} + +func TestDocMediaInsertValidateContract(t *testing.T) { + cases := []struct { + name string + str map[string]string + bools map[string]bool + ints map[string]int + wantParam string + wantParams []string + }{ + { + name: "neither file nor clipboard", + str: map[string]string{"doc": testDocxToken}, + wantParam: "", // one-of-two flags: enumerated in Params + wantParams: []string{"--file", "--from-clipboard"}, + }, + { + name: "file and clipboard together", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png"}, + bools: map[string]bool{"from-clipboard": true}, + wantParam: "", // mutual exclusion: enumerated in Params + wantParams: []string{"--file", "--from-clipboard"}, + }, + { + name: "non-docx document", + str: map[string]string{"doc": "https://example.larksuite.com/doc/xxxxxx", "file": "dummy.png"}, + wantParam: "--doc", + }, + { + name: "blank selection", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "selection-with-ellipsis": " "}, + wantParam: "--selection-with-ellipsis", + }, + { + name: "before without selection", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png"}, + bools: map[string]bool{"before": true}, + wantParam: "--before", + }, + { + name: "invalid file-view", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "bogus"}, + wantParam: "--file-view", + }, + { + name: "file-view without type file", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "card", "type": "image"}, + wantParam: "--file-view", + }, + { + name: "dimensions with non-image type", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "file"}, + ints: map[string]int{"width": 100}, + wantParam: "", // only --width was set here, so only it is enumerated + wantParams: []string{"--width"}, + }, + { + name: "non-positive width", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"width": 0}, + wantParam: "--width", + }, + { + name: "non-positive height", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"height": 0}, + wantParam: "--height", + }, + { + name: "width over maximum", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"width": 10001}, + wantParam: "--width", + }, + { + name: "height over maximum", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"height": 10001}, + wantParam: "--height", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, tc.bools, tc.ints) + err := DocMediaInsert.Validate(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...) + }) + } +} + +func TestValidateCreateV2Contract(t *testing.T) { + cases := []struct { + name string + str map[string]string + wantParam string + wantParams []string + }{ + { + name: "content required", + str: map[string]string{}, + wantParam: "--content", + }, + { + name: "parent token and position mutually exclusive", + str: map[string]string{"content": "", "parent-token": "fldcnX", "parent-position": "my_library"}, + wantParam: "", // mutual exclusion: enumerated in Params + wantParams: []string{"--parent-token", "--parent-position"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, nil, nil) + err := validateCreateV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...) + }) + } +} + +func TestValidateFetchV2Contract(t *testing.T) { + cases := []struct { + name string + str map[string]string + ints map[string]int + wantParam string + wantParams []string + }{ + { + name: "range mode without block ids", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "range"}, + wantParam: "", // either --start-block-id or --end-block-id: enumerated in Params + wantParams: []string{"--start-block-id", "--end-block-id"}, + }, + { + name: "keyword mode without keyword", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "keyword"}, + wantParam: "--keyword", + }, + { + name: "section mode without start block id", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "section"}, + wantParam: "--start-block-id", + }, + { + name: "negative context-before", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "outline"}, + ints: map[string]int{"context-before": -1}, + wantParam: "--context-before", + }, + { + name: "unknown scope", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "bogus"}, + wantParam: "--scope", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, nil, tc.ints) + err := validateFetchV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...) + }) + } +} + +// TestBuildDocsSearchRequestPreservesParseCause pins the --filter parse faults: +// the typed envelope carries Param --filter and chains the original parse error +// so errors.Is/Unwrap traversal keeps the underlying JSON/time-parse detail. +func TestBuildDocsSearchRequestPreservesParseCause(t *testing.T) { + cases := []struct { + name string + filter string + }{ + {"invalid filter json", "{not json"}, + {"invalid open_time start", `{"open_time":{"start":"not-a-time"}}`}, + {"invalid open_time end", `{"open_time":{"end":"not-a-time"}}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildDocsSearchRequest("q", tc.filter, "", "15") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--filter" { + t.Errorf("param = %q, want %q", ve.Param, "--filter") + } + if errors.Unwrap(ve) == nil { + t.Error("parse error not chained: errors.Unwrap == nil") + } + }) + } +} + +// TestWrapDocNetworkErr pins wrapDocNetworkErr's contract: a typed error passes +// through untouched, while a raw error becomes a transport-level NetworkError +// that still chains the original cause for errors.Is/Unwrap. +func TestWrapDocNetworkErr(t *testing.T) { + t.Run("typed error passes through unchanged", func(t *testing.T) { + typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input") + got := wrapDocNetworkErr(typed, "fetch failed") + if got != error(typed) { + t.Fatalf("typed error must pass through unchanged, got %T", got) + } + }) + t.Run("raw error becomes transport network error", func(t *testing.T) { + raw := errors.New("dial tcp: i/o timeout") + got := wrapDocNetworkErr(raw, "fetch failed: %s", "docx") + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("raw error must become *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport) + } + if !errors.Is(got, raw) { + t.Error("cause not chained: errors.Is(got, raw) == false") + } + }) +} + +// TestWrapDocInputFileErr pins that a --file stat/read failure becomes a typed +// validation error tagged with the --file param and the cause preserved, so an +// agent knows which flag to fix even though the shared helper is flag-agnostic. +func TestWrapDocInputFileErr(t *testing.T) { + raw := errors.New("no such file or directory") + got := wrapDocInputFileErr(raw, "file not found") + var ve *errs.ValidationError + if !errors.As(got, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError (%v)", got, got) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--file" { + t.Errorf("param = %q, want %q", ve.Param, "--file") + } + if !errors.Is(got, raw) { + t.Error("cause not chained: errors.Is(got, raw) == false") + } +} + +func TestValidateUpdateV2Contract(t *testing.T) { + cases := []struct { + name string + str map[string]string + wantParam string + }{ + { + name: "command required", + str: map[string]string{"doc": testDocxToken}, + wantParam: "--command", + }, + { + name: "invalid command", + str: map[string]string{"doc": testDocxToken, "command": "bogus"}, + wantParam: "--command", + }, + { + name: "str_replace without pattern", + str: map[string]string{"doc": testDocxToken, "command": "str_replace"}, + wantParam: "--pattern", + }, + { + name: "block_delete without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_delete"}, + wantParam: "--block-id", + }, + { + name: "block_insert_after without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"}, + wantParam: "--block-id", + }, + { + name: "block_insert_after without content", + str: map[string]string{"doc": testDocxToken, "command": "block_insert_after", "block-id": "blkX"}, + wantParam: "--content", + }, + { + name: "block_copy_insert_after without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after"}, + wantParam: "--block-id", + }, + { + name: "block_copy_insert_after without src block ids", + str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after", "block-id": "blkX"}, + wantParam: "--src-block-ids", + }, + { + name: "block_move_after without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_move_after"}, + wantParam: "--block-id", + }, + { + name: "block_move_after without src block ids", + str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX"}, + wantParam: "--src-block-ids", + }, + { + name: "block_move_after rejects content", + str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX", "src-block-ids": "blkY", "content": "x"}, + wantParam: "--content", + }, + { + name: "block_replace without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_replace"}, + wantParam: "--block-id", + }, + { + name: "block_replace without content", + str: map[string]string{"doc": testDocxToken, "command": "block_replace", "block-id": "blkX"}, + wantParam: "--content", + }, + { + name: "overwrite without content", + str: map[string]string{"doc": testDocxToken, "command": "overwrite"}, + wantParam: "--content", + }, + { + name: "append without content", + str: map[string]string{"doc": testDocxToken, "command": "append"}, + wantParam: "--content", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, nil, nil) + err := validateUpdateV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam) + }) + } +} diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 79a162457..1d28481f1 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -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) diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index 5c31495a5..495f5067c 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -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,16 @@ 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").WithParams( + errs.InvalidParam{Name: "--file", Reason: "provide either --file or --from-clipboard"}, + errs.InvalidParam{Name: "--from-clipboard", Reason: "provide either --file or --from-clipboard"}, + ) } 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").WithParams( + errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --from-clipboard"}, + errs.InvalidParam{Name: "--from-clipboard", Reason: "mutually exclusive with --file"}, + ) } docRef, err := parseDocumentRef(runtime.Str("doc")) @@ -78,7 +84,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 +93,43 @@ 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") + var params []errs.InvalidParam + if widthChanged { + params = append(params, errs.InvalidParam{Name: "--width", Reason: "only applies when --type=image"}) + } + if heightChanged { + params = append(params, errs.InvalidParam{Name: "--height", Reason: "only applies when --type=image"}) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image").WithParams(params...) } 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 +282,10 @@ var DocMediaInsert = common.Shortcut{ } else { stat, err := runtime.FileIO().Stat(filePath) if err != nil { - return common.WrapInputStatError(err, "file not found") + return wrapDocInputFileErr(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 +297,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 +331,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 +341,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 +353,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,15 +392,21 @@ var DocMediaInsert = common.Shortcut{ } else { f, openErr := runtime.FileIO().Open(filePath) if openErr != nil { - return withRollbackWarning(output.ErrValidation( - "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName)) + return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument, + "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(openErr).WithParams( + errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"}, + errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"}, + )) } nativeW, nativeH, dimErr = detectImageDimensions(f) f.Close() } if dimErr != nil { - return withRollbackWarning(output.ErrValidation( - "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName)) + return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument, + "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(dimErr).WithParams( + errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"}, + errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"}, + )) } dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH) finalWidth = dims.width @@ -417,7 +436,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 +531,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 +548,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 +641,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 +672,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 +699,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 +757,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 +774,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) { diff --git a/shortcuts/doc/doc_media_preview.go b/shortcuts/doc/doc_media_preview.go index 5b0a7be2b..6bc88d8e4 100644 --- a/shortcuts/doc/doc_media_preview.go +++ b/shortcuts/doc/doc_media_preview.go @@ -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) diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index e7168c623..449dbb40b 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -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 wrapDocInputFileErr(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) diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 70d07ddcb..6f0e1471c 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -7,6 +7,7 @@ import ( "context" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -25,10 +26,13 @@ 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").WithParams( + errs.InvalidParam{Name: "--parent-token", Reason: "mutually exclusive with --parent-position"}, + errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"}, + ) } return nil } diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 305847a2a..0ab446c33 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -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,23 @@ 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").WithParams( + errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"}, + errs.InvalidParam{Name: "--end-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"}, + ) } 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") } } diff --git a/shortcuts/doc/docs_search.go b/shortcuts/doc/docs_search.go index 73dfca4bf..80ebf9680 100644 --- a/shortcuts/doc/docs_search.go +++ b/shortcuts/doc/docs_search.go @@ -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").WithCause(err) } 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").WithCause(err) } 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").WithCause(err) } 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 { diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index fcc7beb17..cc75d44ed 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -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 diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go index c3446d3b8..4305e9ba5 100644 --- a/shortcuts/doc/helpers.go +++ b/shortcuts/doc/helpers.go @@ -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 } diff --git a/shortcuts/doc/v2_only.go b/shortcuts/doc/v2_only.go index e74857806..1d0515166 100644 --- a/shortcuts/doc/v2_only.go +++ b/shortcuts/doc/v2_only.go @@ -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 } From 7fdf55821b566a35edcb3fe1c57ef4bbfe17b7ff Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Tue, 9 Jun 2026 22:43:44 +0800 Subject: [PATCH 02/48] chore(release): v1.0.50 (#1359) --- CHANGELOG.md | 23 +++++++++++++++++++++++ package.json | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8c5b195..a22f931bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to this project will be documented in this file. +## [v1.0.50] - 2026-06-09 + +### Features + +- **doc**: Emit typed error envelopes across the doc domain (#1346) +- **event**: Emit typed error envelopes across the event domain (#1289) +- **contact**: Emit typed error envelopes across the contact domain (#1287) +- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337) +- **cli**: Adjust agent timeout hint output conditions (#1328) + +### Bug Fixes + +- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343) +- **slides**: Build create URL locally instead of drive metas call (#1329) +- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336) + +### Documentation + +- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340) +- **doc**: Document `` resource block (#1168) +- **drive**: Add drive comment location guidance (#1258) + ## [v1.0.49] - 2026-06-08 ### Features @@ -1066,6 +1088,7 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50 [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 diff --git a/package.json b/package.json index bc366cae9..30dbee241 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.49", + "version": "1.0.50", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" From 31bc87a2cc4a52602f236942cabfe93755c9ec7f Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Wed, 10 Jun 2026 11:42:12 +0800 Subject: [PATCH 03/48] feat(vc): add recording event support (#1369) --- events/vc/recording_ended.go | 84 ++++ events/vc/recording_started.go | 84 ++++ events/vc/recording_test.go | 468 ++++++++++++++++++++ events/vc/recording_transcript_generated.go | 163 +++++++ events/vc/register.go | 65 ++- 5 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 events/vc/recording_ended.go create mode 100644 events/vc/recording_started.go create mode 100644 events/vc/recording_test.go create mode 100644 events/vc/recording_transcript_generated.go diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go new file mode 100644 index 000000000..bc0a4e3c7 --- /dev/null +++ b/events/vc/recording_ended.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1. +type VCRecordingEndedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` +} + +type recordingEndedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingEndedEvent `json:"event"` +} + +type recordingEndedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingEndedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingEndedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingEndedOutput{ + Type: recordingEndedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingEndedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + } + return json.Marshal(out) +} + +func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) { + var envelope recordingEndedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingEndedEventTime(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_started.go b/events/vc/recording_started.go new file mode 100644 index 000000000..00d51caf0 --- /dev/null +++ b/events/vc/recording_started.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1. +type VCRecordingStartedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` +} + +type recordingStartedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingStartedEvent `json:"event"` +} + +type recordingStartedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingStartedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingStartedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingStartedOutput{ + Type: recordingStartedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingStartedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + } + return json.Marshal(out) +} + +func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) { + var envelope recordingStartedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingStartedEventTime(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go new file mode 100644 index 000000000..89ba24487 --- /dev/null +++ b/events/vc/recording_test.go @@ -0,0 +1,468 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestVCKeys_RecordingEventsRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + }{ + {eventTypeRecordingStarted}, + {eventTypeRecordingTranscriptGenerated}, + {eventTypeRecordingEnded}, + } { + t.Run(tc.eventType, func(t *testing.T) { + def, ok := event.Lookup(tc.eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", tc.eventType) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } + if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType { + t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents) + } + if !strings.Contains(def.Description, "recording_bean") { + t.Errorf("Description should document recording_bean source, got %q", def.Description) + } + if !strings.Contains(def.Description, "connected to Feishu software") { + t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description) + } + if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") { + t.Errorf("Description should not mention future sources, got %q", def.Description) + } + if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) { + t.Errorf("ended Description should not document object metadata, got %q", def.Description) + } + wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{}) + switch tc.eventType { + case eventTypeRecordingTranscriptGenerated: + wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{}) + case eventTypeRecordingEnded: + wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{}) + } + if def.Schema.Custom.Type != wantSchemaType { + t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType) + } + }) + } +} + +func TestProcessVCRecordingStarted(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_start_001", + "event_type": "vc.recording.recording_started_v1", + "create_time": "1761782400000" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean" + } + }`) + + if out.Type != eventTypeRecordingStarted { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) { + t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } +} + +func TestProcessVCRecordingTranscriptGenerated(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_transcript_001", + "event_type": "vc.recording.recording_transcript_generated_v1", + "create_time": "1761782400100" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "transcript_items": [ + { + "speaker": { + "id": { + "open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53", + "union_id": "on_bc03f16d781bff4178a5d11e48eb1867", + "user_id": null + }, + "user_type": 100, + "user_role": 1, + "user_name": "Alice" + }, + "text": "hello world", + "language": "en_us", + "start_time_ms": "1761782399000", + "end_time_ms": "1761782400000", + "sentence_id": "987654321" + }, + { + "speaker": { + "user_name": "Bob" + }, + "text": "second sentence", + "language": "en_us", + "start_time_ms": "1761782401000", + "end_time_ms": "1761782402000", + "sentence_id": "987654322" + } + ] + } + }`) + if got == nil { + t.Fatal("Process output is nil") + } + var out VCRecordingTranscriptGeneratedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got)) + } + + if out.Type != eventTypeRecordingTranscriptGenerated { + t.Errorf("Type = %q", out.Type) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } + if out.EventTime != recordingTestEventTime(1761782400100) { + t.Errorf("EventTime = %q", out.EventTime) + } + if len(out.TranscriptItems) != 2 { + t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems)) + } + item := out.TranscriptItems[0] + if item.SpeakerName != "Alice" || item.Text != "hello world" { + t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text) + } + if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) { + t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime) + } + if item.SentenceID != "987654321" { + t.Errorf("SentenceID = %q, want 987654321", item.SentenceID) + } + if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" { + t.Errorf("second transcript item = %+v", out.TranscriptItems[1]) + } + itemJSON, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal transcript item: %v", err) + } + var itemFields map[string]any + if err := json.Unmarshal(itemJSON, &itemFields); err != nil { + t.Fatalf("unmarshal transcript item JSON: %v", err) + } + wantItemFields := map[string]bool{ + "speaker_name": true, + "text": true, + "start_time": true, + "end_time": true, + "sentence_id": true, + } + for gotField := range itemFields { + if !wantItemFields[gotField] { + t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON)) + } + } + for wantField := range wantItemFields { + if _, ok := itemFields[wantField]; !ok { + t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON)) + } + } + for _, unexpected := range []string{ + `"seq_id"`, + `"speaker"`, + `"user_open_id"`, + `"user_type"`, + `"user_role"`, + `"language"`, + `"start_time_ms"`, + `"end_time_ms"`, + `"sequence_id"`, + `"transcript_item"`, + } { + if strings.Contains(string(got), unexpected) { + t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got)) + } + } + if !strings.Contains(string(got), `"sentence_id":"987654321"`) { + t.Errorf("Transcript output should contain sentence_id, got %s", string(got)) + } +} + +func TestProcessVCRecordingEnded(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_end_001", + "event_type": "vc.recording.recording_ended_v1", + "create_time": "1761782400200" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "object_type": "minutes", + "object_id": "minute_token_001" + } + }`) + + if out.Type != eventTypeRecordingEnded { + t.Errorf("Type = %q", out.Type) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } + if out.EventTime != recordingTestEventTime(1761782400200) { + t.Errorf("EventTime = %q", out.EventTime) + } +} + +func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_end_001", + "event_type": "vc.recording.recording_ended_v1", + "create_time": "1761782400200" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "object_type": "minutes", + "object_id": "minute_token_001" + } + }`) + + if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") { + t.Fatalf("ended output should drop object metadata, got %s", string(got)) + } +} + +func TestProcessVCRecording_DropsTimestampField(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_start_001", + "event_type": "vc.recording.recording_started_v1", + "create_time": "1761782400000" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean" + } + }`) + + if strings.Contains(string(got), `"timestamp"`) { + t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got)) + } + if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) { + t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got)) + } +} + +func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + payload string + }{ + { + name: "started", + eventType: eventTypeRecordingStarted, + process: processVCRecordingStarted, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording"} + }`, + }, + { + name: "transcript", + eventType: eventTypeRecordingTranscriptGenerated, + process: processVCRecordingTranscriptGenerated, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []} + }`, + }, + { + name: "ended", + eventType: eventTypeRecordingEnded, + process: processVCRecordingEnded, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording"} + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload) + if got != nil { + t.Fatalf("non-recording_bean event should be filtered, got %s", string(got)) + } + }) + } +} + +func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + }{ + {name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted}, + {name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated}, + {name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &event.RawEvent{ + EventType: tc.eventType, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := tc.process(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } + }) + } +} + +func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + }{ + {eventTypeRecordingStarted}, + {eventTypeRecordingTranscriptGenerated}, + {eventTypeRecordingEnded}, + } { + t.Run(tc.eventType, func(t *testing.T) { + def, ok := event.Lookup(tc.eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", tc.eventType) + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, tc.eventType) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, tc.eventType) + }) + } +} + +func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T { + t.Helper() + got := runRecordingProcessRaw(t, eventType, process, payload) + if got == nil { + t.Fatal("Process output is nil") + } + var out T + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got)) + } + return out +} + +func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage { + t.Helper() + raw := &event.RawEvent{ + EventType: eventType, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := process(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + return got +} + +func recordingTestEventTime(millis int64) string { + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_transcript_generated.go b/events/vc/recording_transcript_generated.go new file mode 100644 index 000000000..fe609bbff --- /dev/null +++ b/events/vc/recording_transcript_generated.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events. +type VCRecordingTranscriptItemOutput struct { + SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"` + Text string `json:"text,omitempty" desc:"Transcript text"` + StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"` + EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"` + SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"` +} + +// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1. +type VCRecordingTranscriptGeneratedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` + TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"` +} + +type recordingTranscriptGeneratedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingTranscriptGeneratedEvent `json:"event"` +} + +type recordingTranscriptGeneratedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` + TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"` +} + +type recordingTranscriptGeneratedItemIn struct { + Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"` + Text string `json:"text"` + StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"` + EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"` + SentenceID string `json:"sentence_id"` +} + +type recordingTranscriptGeneratedSpeakerIn struct { + UserName string `json:"user_name"` +} + +type recordingTranscriptGeneratedString string + +func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingTranscriptGeneratedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingTranscriptGeneratedOutput{ + Type: recordingTranscriptGeneratedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems), + } + return json.Marshal(out) +} + +func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) { + var envelope recordingTranscriptGeneratedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingTranscriptGeneratedEventTime(raw string) string { + return recordingTranscriptGeneratedMillisToLocalRFC3339(raw) +} + +func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} + +func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput { + if len(items) == 0 { + return nil + } + out := make([]VCRecordingTranscriptItemOutput, 0, len(items)) + for _, item := range items { + out = append(out, recordingTranscriptItem(item)) + } + return out +} + +func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput { + return VCRecordingTranscriptItemOutput{ + SpeakerName: recordingSpeakerName(item.Speaker), + Text: item.Text, + StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()), + EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()), + SentenceID: item.SentenceID, + } +} + +func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string { + if speaker == nil { + return "" + } + return speaker.UserName +} + +func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var str string + if err := json.Unmarshal(data, &str); err == nil { + *s = recordingTranscriptGeneratedString(str) + return nil + } + var num json.Number + if err := json.Unmarshal(data, &num); err != nil { + return err + } + *s = recordingTranscriptGeneratedString(num.String()) + return nil +} + +func (s recordingTranscriptGeneratedString) String() string { + return string(s) +} diff --git a/events/vc/register.go b/events/vc/register.go index d392daf23..bfee1241b 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -11,13 +11,18 @@ import ( ) const ( - eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" - eventTypeNoteGenerated = "vc.note.generated_v1" + eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" + eventTypeNoteGenerated = "vc.note.generated_v1" + eventTypeRecordingStarted = "vc.recording.recording_started_v1" + eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1" + eventTypeRecordingEnded = "vc.recording.recording_ended_v1" - pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" - pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" - pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" - pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" + pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" + pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" + pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription" + pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription" pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s" ) @@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition { }, RequiredConsoleEvents: []string{eventTypeNoteGenerated}, }, + { + Key: eventTypeRecordingStarted, + DisplayName: "Recording started", + Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.", + EventType: eventTypeRecordingStarted, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})}, + }, + Process: processVCRecordingStarted, + PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingStarted}, + }, + { + Key: eventTypeRecordingTranscriptGenerated, + DisplayName: "Recording transcript generated", + Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.", + EventType: eventTypeRecordingTranscriptGenerated, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})}, + }, + Process: processVCRecordingTranscriptGenerated, + PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated}, + }, + { + Key: eventTypeRecordingEnded, + DisplayName: "Recording ended", + Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.", + EventType: eventTypeRecordingEnded, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})}, + }, + Process: processVCRecordingEnded, + PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingEnded}, + }, } } From b07be60068820854937a24616b22a4966d32104a Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:51:42 +0800 Subject: [PATCH 04/48] feat(sheets): emit typed error envelopes across the sheets domain (#1348) Emit structured validation, API, network, file, and internal error envelopes for Sheets shortcuts so users and agents can recover from failed spreadsheet workflows using stable type, subtype, param, and code fields. Add Sheets domain errscontract and golangci guards to prevent legacy envelope and common helper regressions. --- .golangci.yml | 6 +- .../rule_no_legacy_common_helper_call.go | 1 + .../rule_no_legacy_envelope_literal.go | 1 + lint/errscontract/rules_test.go | 18 +++ shortcuts/sheets/backward/helpers.go | 14 +- .../sheets/backward/lark_sheets_cell_data.go | 21 +-- .../backward/lark_sheets_cell_images.go | 24 +-- .../lark_sheets_cell_style_and_merge.go | 43 +++--- .../sheets/backward/lark_sheets_dropdown.go | 27 ++-- .../backward/lark_sheets_filter_views.go | 30 ++-- .../backward/lark_sheets_float_images.go | 36 +++-- .../lark_sheets_row_column_management.go | 35 +++-- .../backward/lark_sheets_sheet_export_test.go | 17 ++ .../backward/lark_sheets_sheet_manage_test.go | 45 ++---- .../backward/lark_sheets_sheet_management.go | 139 ++++++----------- .../lark_sheets_spreadsheet_management.go | 48 +++--- shortcuts/sheets/backward/sheets_errors.go | 15 ++ shortcuts/sheets/batch_op_dispatch.go | 39 ++--- shortcuts/sheets/csv_put_guard_test.go | 12 ++ shortcuts/sheets/csv_put_range_alias_test.go | 16 +- shortcuts/sheets/execute_paths_test.go | 28 +++- shortcuts/sheets/flag_schema_validate.go | 4 +- shortcuts/sheets/helpers.go | 91 ++++++++--- shortcuts/sheets/helpers_test.go | 67 ++++++++ shortcuts/sheets/lark_sheet_batch_update.go | 20 +-- shortcuts/sheets/lark_sheet_object_crud.go | 62 +++++--- .../sheets/lark_sheet_range_operations.go | 45 +++--- .../lark_sheet_range_operations_test.go | 43 +++--- shortcuts/sheets/lark_sheet_read_data.go | 6 +- shortcuts/sheets/lark_sheet_search_replace.go | 6 +- .../sheets/lark_sheet_sheet_structure.go | 28 ++-- shortcuts/sheets/lark_sheet_workbook.go | 146 +++++++++++------- shortcuts/sheets/lark_sheet_workbook_test.go | 91 +++++++++++ shortcuts/sheets/lark_sheet_write_cells.go | 87 +++++++---- shortcuts/sheets/sheet_ai_api.go | 13 +- shortcuts/sheets/validation_params_test.go | 104 +++++++++++++ 36 files changed, 944 insertions(+), 484 deletions(-) create mode 100644 shortcuts/sheets/backward/sheets_errors.go create mode 100644 shortcuts/sheets/validation_params_test.go diff --git a/.golangci.yml b/.golangci.yml index 2e52c6669..9428d7a2b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index a1e404ff6..742b506dd 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -27,6 +27,7 @@ var migratedCommonHelperPaths = []string{ "shortcuts/mail/", "shortcuts/minutes/", "shortcuts/okr/", + "shortcuts/sheets/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index e75bb3fca..e7bba084c 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -28,6 +28,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/mail/", "shortcuts/minutes/", "shortcuts/okr/", + "shortcuts/sheets/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 410bb6f8e..22a7b396c 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -954,6 +954,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "shortcuts/drive/drive_search.go", "shortcuts/mail/mail_send.go", "shortcuts/okr/okr_progress_create.go", + "shortcuts/sheets/helpers.go", "shortcuts/task/task_update.go", "shortcuts/whiteboard/whiteboard_query.go", } @@ -1021,6 +1022,23 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/shortcuts/sheets/backward/helpers.go b/shortcuts/sheets/backward/helpers.go index 9c8f3284c..46fdc14fe 100644 --- a/shortcuts/sheets/backward/helpers.go +++ b/shortcuts/sheets/backward/helpers.go @@ -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 ! prefix", input) + return common.ValidationErrorf("--range %q requires --sheet-id or a ! prefix", input).WithParam("--range") } 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).WithParam("--range") } 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 { diff --git a/shortcuts/sheets/backward/lark_sheets_cell_data.go b/shortcuts/sheets/backward/lark_sheets_cell_data.go index 59d6fa74e..77a1571be 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_data.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_data.go @@ -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), diff --git a/shortcuts/sheets/backward/lark_sheets_cell_images.go b/shortcuts/sheets/backward/lark_sheets_cell_images.go index d2c0af692..6f1a3cb56 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_images.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_images.go @@ -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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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) } diff --git a/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go index 520f91e36..30622c052 100644 --- a/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go @@ -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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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{}{ diff --git a/shortcuts/sheets/backward/lark_sheets_dropdown.go b/shortcuts/sheets/backward/lark_sheets_dropdown.go index e5645af65..6e07dad3e 100644 --- a/shortcuts/sheets/backward/lark_sheets_dropdown.go +++ b/shortcuts/sheets/backward/lark_sheets_dropdown.go @@ -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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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. !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. !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. !A2:A100)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. !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. !A2:A100)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. !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, }, diff --git a/shortcuts/sheets/backward/lark_sheets_filter_views.go b/shortcuts/sheets/backward/lark_sheets_filter_views.go index b76a473f3..fc8105e18 100644 --- a/shortcuts/sheets/backward/lark_sheets_filter_views.go +++ b/shortcuts/sheets/backward/lark_sheets_filter_views.go @@ -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").WithParams(errs.InvalidParam{Name: "--range", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--filter-view-name", Reason: "required; specify at least one"}) } 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").WithParams(errs.InvalidParam{Name: "--filter-type", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--compare-type", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--expected", Reason: "required; specify at least one"}) } 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 } diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go index a0b7bf490..a06cb7f20 100644 --- a/shortcuts/sheets/backward/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -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,14 @@ 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") + wrapped := common.WrapInputStatErrorTyped(err, "file not found") + if v, ok := wrapped.(*errs.ValidationError); ok { + return "", nil, v.WithParam("--file") + } + return "", nil, wrapped } 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 +135,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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return token, nil } @@ -181,7 +185,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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } return token, nil } @@ -194,7 +198,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 +210,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").WithParams(errs.InvalidParam{Name: "--range", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--width", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--height", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--offset-x", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--offset-y", Reason: "required; specify at least one"}) } return nil } @@ -214,22 +218,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 +308,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 +357,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 +391,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 +424,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 +458,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 } diff --git a/shortcuts/sheets/backward/lark_sheets_row_column_management.go b/shortcuts/sheets/backward/lark_sheets_row_column_management.go index 581c8eb0e..ea1e913a8 100644 --- a/shortcuts/sheets/backward/lark_sheets_row_column_management.go +++ b/shortcuts/sheets/backward/lark_sheets_row_column_management.go @@ -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").WithParams(errs.InvalidParam{Name: "--visible", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--fixed-size", Reason: "required; specify at least one"}) } 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{}{ diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go index afe456b5d..60d5435c9 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go @@ -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() diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go index 1a8115b7a..ea52fe159 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go @@ -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") } } diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_management.go b/shortcuts/sheets/backward/lark_sheets_sheet_management.go index 0484a6cd4..4988fdd4c 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_management.go @@ -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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } token := extractSpreadsheetToken(url) if token == "" || token == url { - return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/").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").WithParams(errs.InvalidParam{Name: "--title", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--index", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--hidden", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--frozen-row-count", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--frozen-col-count", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--lock", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--lock-info", Reason: "required; specify at least one"}, errs.InvalidParam{Name: "--user-ids", Reason: "required; specify at least one"}) } 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 } diff --git a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go index 5aa1fdaec..2f9314f92 100644 --- a/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go @@ -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").WithParams(errs.InvalidParam{Name: "--url", Reason: "required; specify one"}, errs.InvalidParam{Name: "--spreadsheet-token", Reason: "required; specify one"}) } 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 +} diff --git a/shortcuts/sheets/backward/sheets_errors.go b/shortcuts/sheets/backward/sheets_errors.go new file mode 100644 index 000000000..65e3bc7a6 --- /dev/null +++ b/shortcuts/sheets/backward/sheets_errors.go @@ -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) +} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 271d4eeca..5f7d492fa 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -5,8 +5,6 @@ package sheets import ( "strings" - - "github.com/larksuite/cli/shortcuts/common" ) // ─── +batch-update sub-op dispatch ───────────────────────────────────── @@ -198,7 +196,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 sheetsValidationForFlag("image", "--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead") } return nil } @@ -208,23 +206,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, sheetsValidationForFlag("sheet-id", "+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, sheetsValidationForFlag("source-index", "+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, sheetsValidationForFlag("source-index", "--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, sheetsValidationForFlag("index", "+sheet-move in +batch-update requires index") } if fv.Int("index") < 0 { - return nil, common.FlagErrorf("--index must be >= 0") + return nil, sheetsValidationForFlag("index", "--index must be >= 0") } return map[string]interface{}{ "excel_id": token, @@ -254,19 +252,20 @@ 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, sheetsValidationForFlag("operations", "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, sheetsValidationForFlag("operations", "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, sheetsValidationForFlag("operations", "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, sheetsValidationForFlag( + "operations", "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 +279,13 @@ 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, sheetsValidationForFlag("operations", "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, sheetsValidationForFlag( + "operations", "operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name", index, sc, ) @@ -293,7 +293,8 @@ 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, sheetsValidationForFlag( + "operations", "operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token", index, sc, k, ) @@ -302,7 +303,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, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) } } fv := newMapFlagViewForCommand(sc, input) @@ -310,14 +311,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, sheetsValidationForFlag("operations", "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, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err) } return map[string]interface{}{ "tool_name": mapping.mcpToolName, @@ -328,7 +329,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, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array") } out := make([]interface{}, 0, len(rawOps)) for i, raw := range rawOps { diff --git a/shortcuts/sheets/csv_put_guard_test.go b/shortcuts/sheets/csv_put_guard_test.go index 5191e45ee..29a47815c 100644 --- a/shortcuts/sheets/csv_put_guard_test.go +++ b/shortcuts/sheets/csv_put_guard_test.go @@ -4,10 +4,12 @@ package sheets import ( + "errors" "os" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" _ "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" @@ -41,6 +43,16 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) { if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") { t.Errorf("error should flag the file and suggest @data.csv, got: %v", err) } + if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("problem = %+v, want validation/invalid_argument", p) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("guard error = %T, want *errs.ValidationError", err) + } + if ve.Param != "--csv" { + t.Errorf("param = %q, want --csv", ve.Param) + } // Content that is not a real file must pass through unchanged. for _, v := range []string{ diff --git a/shortcuts/sheets/csv_put_range_alias_test.go b/shortcuts/sheets/csv_put_range_alias_test.go index 4a631d6f6..e4157c22b 100644 --- a/shortcuts/sheets/csv_put_range_alias_test.go +++ b/shortcuts/sheets/csv_put_range_alias_test.go @@ -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 diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 8cd24bb25..c04a8caab 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -5,11 +5,12 @@ package sheets import ( "encoding/json" + "errors" "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 +454,27 @@ 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 p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype) } - 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.Message, "shtNEW") { + t.Errorf("message = %q, want spreadsheet token for recovery", p.Message) + } + if !strings.Contains(p.Hint, "spreadsheet_token") { + t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint) + } + // The underlying fill failure is preserved as the cause so its subtype and + // log_id stay diagnosable rather than being flattened into the message. + inner := errors.Unwrap(err) + if inner == nil { + t.Fatalf("expected the underlying fill failure preserved as the cause") + } + if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse { + t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner) } } diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go index fb3299f2c..701a859a1 100644 --- a/shortcuts/sheets/flag_schema_validate.go +++ b/shortcuts/sheets/flag_schema_validate.go @@ -8,8 +8,6 @@ import ( "fmt" "sort" "strings" - - "github.com/larksuite/cli/shortcuts/common" ) // ─── schema-driven flag validation ──────────────────────────────────── @@ -95,7 +93,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 sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error()) } return nil } diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 3465484cb..621f214eb 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -12,20 +12,52 @@ 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) +} + +// sheetsInputStatError wraps a local input-file stat/open failure as a typed +// validation error tagged with the flag the path came from, so callers learn +// which flag to fix. It reuses the shared common.WrapInputStatErrorTyped +// classification and only adds the domain's flag param. +func sheetsInputStatError(flag string, err error) error { + wrapped := common.WrapInputStatErrorTyped(err) + if v, ok := wrapped.(*errs.ValidationError); ok { + return v.WithParam(sheetsFlagParam(flag)) + } + return wrapped +} + // 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 +65,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/") + return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/") } if err := validate.RejectControlChars(token, "url"); err != nil { - return "", common.FlagErrorf("%v", err) + return "", sheetsValidationCauseForFlag("url", err) } return token, nil } @@ -64,18 +96,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 +148,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 +192,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 +241,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 +260,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 +276,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 +337,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 +351,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"), + ) } diff --git a/shortcuts/sheets/helpers_test.go b/shortcuts/sheets/helpers_test.go index 15eb06166..686676955 100644 --- a/shortcuts/sheets/helpers_test.go +++ b/shortcuts/sheets/helpers_test.go @@ -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: // diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go index 696c40f23..ad58db0f3 100644 --- a/shortcuts/sheets/lark_sheet_batch_update.go +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -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, sheetsValidationForFlag("operations", "--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, sheetsValidationForFlag("operations", "--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, sheetsValidationForFlag("range", "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 sheetsValidationForFlag("ranges", "--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, sheetsValidationForFlag("range", "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, sheetsValidationForFlag("ranges", "--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, sheetsValidationForFlag("ranges", "--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, sheetsValidationForFlag("ranges", "--ranges[%d]: %v", i, err) } if _, _, err := rangeDimensions(sub); err != nil { - return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err) + return nil, sheetsValidationForFlag("ranges", "--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 "", "", sheetsValidationForFlag("range", "range %q must use sheet!range form", rng) } return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil } diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 221b9d03d..410be3d87 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -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, sheetsValidationForFlag(spec.idFlag, "--%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, sheetsValidationForFlag(spec.idFlag, "--%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 sheetsValidationForFlag("properties", "+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 sheetsValidationForFlag("properties", "+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 ` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i) + return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-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,44 @@ 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").WithParams(sheetsInvalidParam("image", "required; specify one"), sheetsInvalidParam("image-token", "required; specify one"), sheetsInvalidParam("image-uri", "required; specify one")) } if set > 1 { - return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive") + params := make([]errs.InvalidParam, 0, 3) + if img != "" { + params = append(params, sheetsInvalidParam("image", "mutually exclusive")) + } + if token != "" { + params = append(params, sheetsInvalidParam("image-token", "mutually exclusive")) + } + if uri != "" { + params = append(params, sheetsInvalidParam("image-uri", "mutually exclusive")) + } + return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive").WithParams(params...) } name := floatImageName(runtime) if name == "" { - return nil, common.FlagErrorf("--image-name is required") + return nil, sheetsValidationForFlag("image-name", "--image-name is required") } if !runtime.Changed("position-row") || !runtime.Changed("position-col") { - return nil, common.FlagErrorf("--position-row and --position-col are required") + params := make([]errs.InvalidParam, 0, 2) + if !runtime.Changed("position-row") { + params = append(params, sheetsInvalidParam("position-row", "required")) + } + if !runtime.Changed("position-col") { + params = append(params, sheetsInvalidParam("position-col", "required")) + } + return nil, common.ValidationErrorf("--position-row and --position-col are required").WithParams(params...) } if !runtime.Changed("size-width") || !runtime.Changed("size-height") { - return nil, common.FlagErrorf("--size-width and --size-height are required") + params := make([]errs.InvalidParam, 0, 2) + if !runtime.Changed("size-width") { + params = append(params, sheetsInvalidParam("size-width", "required")) + } + if !runtime.Changed("size-height") { + params = append(params, sheetsInvalidParam("size-height", "required")) + } + return nil, common.ValidationErrorf("--size-width and --size-height are required").WithParams(params...) } props := map[string]interface{}{ "image_name": name, @@ -626,7 +650,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 +772,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st } info, err := runtime.FileIO().Stat(img) if err != nil { - return "", common.WrapInputStatError(err) + return "", sheetsInputStatError("image", err) } return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FilePath: img, @@ -762,7 +788,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, sheetsValidationForFlag("float-image-id", "--float-image-id is required") } props, err := floatImageProperties(runtime, uploadedImageToken, op == "create") if err != nil { @@ -882,7 +908,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, sheetsValidationForFlag("range", "--range is required") } props := map[string]interface{}{ "range": strings.TrimSpace(runtime.Str("range")), @@ -957,10 +983,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, sheetsValidationForFlag("sheet-id", "+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, sheetsValidationForFlag("range", "--range is required") } props, err := requireJSONObject(runtime, "properties") if err != nil { @@ -1031,7 +1057,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, sheetsValidationForFlag("sheet-id", "+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, diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 669e5eaf5..188fdfe73 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -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, sheetsValidationForFlag("range", "--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, sheetsValidationForFlag("range", "--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, sheetsValidationForFlag("range", "--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, sheetsValidationForFlag("range", "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, sheetsValidationForFlag("range", "--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, sheetsValidationForFlag("type", "--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, sheetsValidationForFlag("type", "--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 ") + return nil, common.ValidationErrorf("--type pixel requires --size ").WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam("size", "required")) } 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").WithParams(sheetsInvalidParam("size", "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive")) } 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, sheetsValidationForFlag("source-range", "--source-range is required") } if strings.TrimSpace(runtime.Str("target-range")) == "" { - return nil, common.FlagErrorf("--target-range is required") + return nil, sheetsValidationForFlag("target-range", "--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, sheetsValidationForFlag("source-range", "--source-range is required") } if strings.TrimSpace(runtime.Str("target-range")) == "" { - return nil, common.FlagErrorf("--target-range is required") + return nil, sheetsValidationForFlag("target-range", "--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, sheetsValidationForFlag("range", "--range is required") } // requireJSONArray runs the embedded JSON Schema for --sort-keys // via parseJSONFlag → validateParsedJSONFlag, so each item is diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index e0f464709..0c85bbb66 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -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) } }) diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go index 40044c94a..034e541bf 100644 --- a/shortcuts/sheets/lark_sheet_read_data.go +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("range")) == "" { - return common.FlagErrorf("--range is required") + return sheetsValidationForFlag("range", "--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 sheetsValidationForFlag("range", "--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 sheetsValidationForFlag("range", "--range is required") } return nil }, diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index 6e0b8ecb3..4777d2454 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -36,7 +36,7 @@ var CellsSearch = common.Shortcut{ return err } if strings.TrimSpace(runtime.Str("find")) == "" { - return common.FlagErrorf("--find is required") + return sheetsValidationForFlag("find", "--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, sheetsValidationForFlag("find", "--find is required") } if !runtime.Changed("replacement") { - return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)") + return nil, sheetsValidationForFlag("replacement", "--replacement is required (pass an empty string to delete matches)") } input := map[string]interface{}{ "excel_id": token, diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index fcdd9667a..41228578c 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -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, sheetsValidationForFlag("position", "--position is required") } if !runtime.Changed("count") { - return nil, common.FlagErrorf("--count is required") + return nil, sheetsValidationForFlag("count", "--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, sheetsValidationForFlag("position", "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, sheetsValidationForFlag("count", "--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, sheetsValidationForFlag("dimension", "--dimension is required") } if !runtime.Changed("count") { - return nil, common.FlagErrorf("--count is required (0 unfreezes)") + return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)") } if runtime.Int("count") < 0 { - return nil, common.FlagErrorf("--count must be >= 0") + return nil, sheetsValidationForFlag("count", "--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, sheetsValidationForFlag("range", "--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, sheetsValidationForFlag("range", "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").WithParams(sheetsInvalidParam("source-range", "required"), sheetsInvalidParam("target", "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, sheetsValidationForFlag("source-range", "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, sheetsValidationForFlag("target", "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).WithParams(sheetsInvalidParam("target", "dimension mismatch"), sheetsInvalidParam("source-range", "dimension mismatch")) } return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil } diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 6ea725b5a..aed687f36 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -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, sheetsValidationForFlag("title", "--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, sheetsValidationForFlag("row-count", "--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, sheetsValidationForFlag("col-count", "--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, sheetsValidationForFlag("title", "--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, sheetsValidationForFlag("color", "--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 sheetsValidationForFlag("index", "--index is required") } if runtime.Int("index") < 0 { - return common.FlagErrorf("--index must be >= 0") + return sheetsValidationForFlag("index", "--index must be >= 0") } if runtime.Changed("source-index") && runtime.Int("source-index") < 0 { - return common.FlagErrorf("--source-index must be >= 0") + return sheetsValidationForFlag("source-index", "--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 sheetsValidationForFlag("title", "--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 sheetsValidationForFlag("headers", "--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 sheetsValidationForFlag("values", "--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 sheetsValidationForFlag("values", "--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} @@ -641,14 +641,12 @@ var WorkbookCreate = common.Shortcut{ // response doesn't echo the default sheet's id, so read it back. firstSheetID, err := lookupFirstSheetID(ctx, runtime, token) if err != nil { - return workbookCreatedButFillFailed(token, ss, - fmt.Sprintf("resolving its first sheet for initial fill failed: %v", err)) + return workbookCreatedButFillFailed(token, "resolving its first sheet for initial fill failed", err) } fill["sheet_id"] = firstSheetID fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) if err != nil { - return workbookCreatedButFillFailed(token, ss, - fmt.Sprintf("initial fill failed: %v", err)) + return workbookCreatedButFillFailed(token, "initial fill failed", err) } result["initial_fill"] = fillOut } @@ -660,24 +658,17 @@ var WorkbookCreate = common.Shortcut{ }, } -// workbookCreatedButFillFailed builds a structured partial-success error for the -// window where the spreadsheet POST succeeded but the follow-up initial fill did -// 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, - }, - }, - } +// workbookCreatedButFillFailed reports the window where the spreadsheet POST +// succeeded but the follow-up initial fill did not. The spreadsheet now exists, +// so the caller must change state (retry the fill against the returned token, or +// delete the orphan) rather than re-run create — a failed_precondition. The +// underlying fill failure is kept as the cause so its subtype and log_id stay +// diagnosable, and the created spreadsheet_token is named in the message and +// recovery hint so the caller can act on it. +func workbookCreatedButFillFailed(token, reason string, cause error) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "spreadsheet %s created but %s", token, reason). + WithCause(cause). + WithHint("the spreadsheet exists; retry the fill with the returned spreadsheet_token (+cells-set / +csv-put), or delete it") } // buildInitialFillInput zips --headers + --values into a single set_cell_range @@ -765,7 +756,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 sheetsValidationForFlag("sheet-id", "--sheet-id is required when --file-extension=csv") } return nil }, @@ -813,13 +804,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 +838,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 +878,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 +889,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 +909,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 +926,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 +935,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 +998,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 +1017,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 +1026,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 +1043,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 +1071,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 } diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go index c7cad75f9..48baa5baf 100644 --- a/shortcuts/sheets/lark_sheet_workbook_test.go +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -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. diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 9cb51fbae..09030e506 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -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, sheetsValidationForFlag("range", "--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, sheetsValidationForFlag("range", "--range is required") } rows, cols, err := rangeDimensions(rangeStr) if err != nil { - return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + return nil, sheetsValidationForFlag("range", "--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: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := guardCSVValueIsNotFilePath(runtime); err != nil { @@ -324,7 +325,7 @@ func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error { if err != nil || info == nil || info.IsDir() { return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @ } - return common.FlagErrorf( + return sheetsValidationForFlag("csv", "--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)", raw, raw, ) @@ -335,7 +336,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, sheetsValidationForFlag("csv", "--csv is required") + } + if runtime.Changed("start-cell") && runtime.Changed("range") { + return nil, common.ValidationErrorf("--start-cell and --range are mutually exclusive").WithParams(sheetsInvalidParam("start-cell", "mutually exclusive"), sheetsInvalidParam("range", "mutually exclusive")) } anchor := strings.TrimSpace(runtime.Str("start-cell")) // --range is accepted as an alias for --start-cell. +csv-get and +cells-set @@ -346,23 +350,27 @@ 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").WithParams(sheetsInvalidParam("start-cell", "required; specify exactly one"), sheetsInvalidParam("range", "required; specify exactly one")) } anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0]) + if idx := strings.Index(anchor, "!"); idx >= 0 { + anchor = anchor[idx+1:] + } } if anchor == "" { - return nil, common.FlagErrorf("--start-cell is required") + return nil, sheetsValidationForFlag("start-cell", "--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, sheetsValidationForFlag("start-cell", "--start-cell %q must be a single cell ref (e.g. A1)", anchor) } input := map[string]interface{}{ "excel_id": token, @@ -433,11 +441,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, sheetsValidationForFlag("range", "--range is required") } rows, cols, err := rangeDimensions(rangeStr) if err != nil { - return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err) } validation, err := buildDropdownValidation(runtime) if err != nil { @@ -496,7 +504,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, sheetsValidationForFlag("colors", "--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } dv["highlight_colors"] = colors } @@ -518,9 +526,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").WithParams(sheetsInvalidParam("options", "mutually exclusive"), sheetsInvalidParam("source-range", "mutually exclusive")) 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").WithParams(sheetsInvalidParam("options", "required; specify exactly one"), sheetsInvalidParam("source-range", "required; specify exactly one")) case optsRaw != "": options, err := requireJSONArray(runtime, "options") if err != nil { @@ -533,7 +541,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, sheetsValidationForFlag("source-range", "--source-range %q: %v", sourceRange, err) } return rows * cols, map[string]interface{}{ "type": "listFromRange", @@ -558,7 +566,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, sheetsValidationForFlag("colors", "--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) } } return sourceSize, nil @@ -731,18 +739,18 @@ var CellsSetImage = common.Shortcut{ } r := strings.TrimSpace(runtime.Str("range")) if r == "" { - return common.FlagErrorf("--range is required") + return sheetsValidationForFlag("range", "--range is required") } rows, cols, err := rangeDimensions(r) if err != nil { - return common.FlagErrorf("--range %q: %v", r, err) + return sheetsValidationForFlag("range", "--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 sheetsValidationForFlag("range", "--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 sheetsValidationForFlag("image", "--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. @@ -750,7 +758,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 }, @@ -806,16 +816,18 @@ var CellsSetImage = common.Shortcut{ } info, err := runtime.FileIO().Stat(imgPath) if err != nil { - return common.WrapInputStatError(err) + return sheetsInputStatError("image", err) } imgFile, err := runtime.FileIO().Open(imgPath) if err != nil { - return common.WrapInputStatError(err) + return sheetsInputStatError("image", err) } imgCfg, _, err := image.DecodeConfig(imgFile) imgFile.Close() if err != nil { - return fmt.Errorf("decode image dimensions: %w", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "decode image dimensions: %s", err). + WithParam("--image"). + WithCause(err) } fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FilePath: imgPath, @@ -844,7 +856,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, @@ -857,3 +869,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) +} diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go index eb4368476..78560d88b 100644 --- a/shortcuts/sheets/sheet_ai_api.go +++ b/shortcuts/sheets/sheet_ai_api.go @@ -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 } diff --git a/shortcuts/sheets/validation_params_test.go b/shortcuts/sheets/validation_params_test.go new file mode 100644 index 000000000..c12177737 --- /dev/null +++ b/shortcuts/sheets/validation_params_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" +) + +// TestValidationParamMetadata locks the structured-param contract across the +// sheets domain: a validation failure must carry typed metadata +// (category/subtype via errs.ProblemOf) and tag the offending flag(s) via +// Param/Params, so consumers (agents) know which flag to fix without parsing +// the human message. Covers the four representative shapes — required, +// at-least-one, mutually-exclusive, and local-input-file errors. +func TestValidationParamMetadata(t *testing.T) { + t.Parallel() + + // assertValidationProblem checks the typed metadata (category + subtype) via + // errs.ProblemOf and returns the ValidationError for param-level assertions. + assertValidationProblem := func(t *testing.T, err error) *errs.ValidationError { + t.Helper() + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error = %T %v, want typed problem", err, err) + } + if p.Category != errs.CategoryValidation { + t.Errorf("category = %q, want %q", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error = %T, want *errs.ValidationError", err) + } + return ve + } + + assertParam := func(t *testing.T, err error, want string) { + t.Helper() + if ve := assertValidationProblem(t, err); ve.Param != want { + t.Fatalf("param = %q, want %q", ve.Param, want) + } + } + + assertParams := func(t *testing.T, err error, want ...string) { + t.Helper() + ve := assertValidationProblem(t, err) + got := map[string]bool{} + for _, p := range ve.Params { + got[p.Name] = true + } + if len(ve.Params) != len(want) { + t.Fatalf("params = %#v, want %v", ve.Params, want) + } + for _, w := range want { + if !got[w] { + t.Fatalf("params = %#v, missing %q", ve.Params, w) + } + } + } + + t.Run("required flag tags single param", func(t *testing.T) { + t.Parallel() + // --image-token satisfies the image source, so the missing --image-name + // trips the single-flag required check routed through sheetsValidationForFlag. + fv := newMapFlagViewForCommand("+float-image-create", map[string]interface{}{"image-token": "tok"}) + _, err := floatImageProperties(fv, "", true) + assertParam(t, err, "--image-name") + }) + + t.Run("at-least-one tags every candidate flag", func(t *testing.T) { + t.Parallel() + fv := newMapFlagViewForCommand("+float-image-create", map[string]interface{}{}) + _, err := floatImageProperties(fv, "", true) + assertParams(t, err, "--image", "--image-token", "--image-uri") + }) + + t.Run("mutually exclusive tags only the conflicting flags", func(t *testing.T) { + t.Parallel() + // Only --image and --image-token are set; the param list must not blame + // the untouched --image-uri. + fv := newMapFlagViewForCommand("+float-image-create", map[string]interface{}{ + "image": "a.png", + "image-token": "tok", + }) + _, err := floatImageProperties(fv, "", true) + assertParams(t, err, "--image", "--image-token") + }) + + t.Run("local input file error tags flag and preserves cause", func(t *testing.T) { + t.Parallel() + cause := errors.New("stat failed") + err := sheetsInputStatError("image", cause) + assertParam(t, err, "--image") + if !errors.Is(err, cause) { + t.Errorf("expected the original stat error preserved as the cause") + } + }) +} From 6b48a39d552e1451964f99b73d31608bc7d67a83 Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:08:25 +0800 Subject: [PATCH 05/48] feat(slides): emit typed error envelopes across the slides domain (#1349) Emit structured validation, API, network, file, and internal error envelopes for Slides shortcuts so users and agents can recover from failed presentation workflows using stable type, subtype, param, and code fields. Add Slides domain errscontract and golangci guards to prevent legacy envelope and common helper regressions. --- .golangci.yml | 6 +- .../rule_no_legacy_common_helper_call.go | 1 + .../rule_no_legacy_envelope_literal.go | 1 + lint/errscontract/rules_test.go | 18 +++ shortcuts/slides/helpers.go | 24 ++-- shortcuts/slides/slides_create.go | 34 +++--- shortcuts/slides/slides_create_test.go | 87 ++++++++++++-- shortcuts/slides/slides_errors.go | 50 ++++++++ shortcuts/slides/slides_errors_test.go | 111 ++++++++++++++++++ shortcuts/slides/slides_media_upload.go | 12 +- shortcuts/slides/slides_media_upload_test.go | 10 ++ shortcuts/slides/slides_replace_slide.go | 52 ++++---- shortcuts/slides/slides_replace_slide_test.go | 77 +++++++++--- 13 files changed, 397 insertions(+), 86 deletions(-) create mode 100644 shortcuts/slides/slides_errors.go create mode 100644 shortcuts/slides/slides_errors_test.go diff --git a/.golangci.yml b/.golangci.yml index 9428d7a2b..4e3b6c42f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index 742b506dd..5f096bc56 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -28,6 +28,7 @@ var migratedCommonHelperPaths = []string{ "shortcuts/minutes/", "shortcuts/okr/", "shortcuts/sheets/", + "shortcuts/slides/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index e7bba084c..ef2a3e167 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -29,6 +29,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/minutes/", "shortcuts/okr/", "shortcuts/sheets/", + "shortcuts/slides/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 22a7b396c..94cf58113 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -955,6 +955,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "shortcuts/mail/mail_send.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", } @@ -1039,6 +1040,23 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/shortcuts/slides/helpers.go b/shortcuts/slides/helpers.go index c8722b209..2c2212264 100644 --- a/shortcuts/slides/helpers.go +++ b/shortcuts/slides/helpers.go @@ -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,18 @@ 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) + // Unreachable: ref.Kind is set only by parsePresentationRef, which + // emits exclusively "slides" or "wiki". A hit here means an internal + // invariant broke (e.g. a new kind added without updating this switch), + // not bad user input — classify as internal, not validation. + return "", errs.NewInternalError(errs.SubtypeUnknown, "unsupported presentation ref kind %q", ref.Kind) } } @@ -191,7 +195,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]] diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index b54deb02d..115f04cd1 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -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" ) @@ -45,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, "--slides", 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") } } } @@ -128,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, @@ -144,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{}{ @@ -168,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) @@ -185,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}, @@ -194,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) @@ -256,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, "--slides", 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", @@ -267,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 } diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go index 2470fdfcb..36b964a07 100644 --- a/shortcuts/slides/slides_create_test.go +++ b/shortcuts/slides/slides_create_test.go @@ -6,12 +6,14 @@ package slides import ( "bytes" "encoding/json" + "errors" "os" "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" @@ -400,15 +402,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) } } @@ -457,6 +465,71 @@ func TestSlidesCreateWithSlidesExceedsMax(t *testing.T) { } } +// TestSlidesCreateValidationParam locks Param=="--slides" on the pure +// validation rejections, so callers route on the typed field rather than the +// message. +func TestSlidesCreateValidationParam(t *testing.T) { + t.Parallel() + + elems := make([]string, 11) + for i := range elems { + elems[i] = `""` + } + exceedsMax := "[" + strings.Join(elems, ",") + "]" + + tests := []struct { + name string + slides string + }{ + {"invalid JSON", "not json"}, + {"exceeds max", exceedsMax}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--slides", tt.slides, + "--as", "user", + }) + + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != "--slides" { + t.Fatalf("Param = %q, want --slides", ve.Param) + } + }) + } +} + +// TestSlidesCreatePlaceholderMissingParam guards the create.go caller wiring: +// a missing @-placeholder file must surface a --slides-tagged validation error +// through the shared slidesInputStatError helper. +func TestSlidesCreatePlaceholderMissingParam(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + slidesJSON := `[""]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--slides", slidesJSON, + "--as", "user", + }) + + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != "--slides" { + t.Fatalf("Param = %q, want --slides", ve.Param) + } +} + // TestSlidesCreateWithSlidesEmptyArray verifies that --slides '[]' behaves like no --slides. func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) { t.Parallel() diff --git a/shortcuts/slides/slides_errors.go b/shortcuts/slides/slides_errors.go new file mode 100644 index 000000000..9856cf1a1 --- /dev/null +++ b/shortcuts/slides/slides_errors.go @@ -0,0 +1,50 @@ +// 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 and tagging +// the offending flag via param so callers route on the typed Param rather than +// parsing the 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, param, 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).WithParam(param).WithCause(err) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithParam(param).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.SubtypeUnknown, "%s", err.Error()).WithHint(hint).WithCause(err) +} diff --git a/shortcuts/slides/slides_errors_test.go b/shortcuts/slides/slides_errors_test.go new file mode 100644 index 000000000..f175402e0 --- /dev/null +++ b/shortcuts/slides/slides_errors_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// TestSlidesInputStatError verifies the shared stat-error helper tags the +// offending flag via the typed Param — so callers route on the structured +// field rather than parsing the message — and always classifies as a +// validation error while preserving the underlying cause. +func TestSlidesInputStatError(t *testing.T) { + t.Parallel() + + if err := slidesInputStatError(nil, "--slides", "ctx"); err != nil { + t.Fatalf("nil input should return nil, got %v", err) + } + + tests := []struct { + name string + in error + }{ + {"path validation", fileio.ErrPathValidation}, + {"generic stat error", errors.New("permission denied")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := slidesInputStatError(tt.in, "--file", "file not found") + + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } + if !errors.Is(err, tt.in) { + t.Fatalf("err must wrap the underlying cause %v", tt.in) + } + }) + } +} + +// TestAppendSlidesProgressHint covers both branches of the orchestration-hint +// helper: a typed error keeps its classification and gains (or extends) the +// progress hint, while an unclassified error surfaced from a shared-helper +// boundary falls back to a typed internal error that still carries the hint +// and the original cause. +func TestAppendSlidesProgressHint(t *testing.T) { + t.Parallel() + + if err := appendSlidesProgressHint(nil, "hint"); err != nil { + t.Fatalf("nil input should return nil, got %v", err) + } + + t.Run("typed error preserves classification and sets hint", func(t *testing.T) { + t.Parallel() + base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input") + err := appendSlidesProgressHint(base, "2 image(s) uploaded before failure") + + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want classification preserved as *errs.ValidationError", err) + } + p, _ := errs.ProblemOf(err) + if p.Hint != "2 image(s) uploaded before failure" { + t.Fatalf("Hint = %q, want the progress hint", p.Hint) + } + }) + + t.Run("typed error appends to an existing hint", func(t *testing.T) { + t.Parallel() + base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input").WithHint("first") + err := appendSlidesProgressHint(base, "second") + + p, _ := errs.ProblemOf(err) + if p.Hint != "first\nsecond" { + t.Fatalf("Hint = %q, want %q", p.Hint, "first\nsecond") + } + }) + + t.Run("unclassified error falls back to typed internal error", func(t *testing.T) { + t.Parallel() + cause := errors.New("raw boundary error") + err := appendSlidesProgressHint(cause, "presentation was created") + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %v, want a typed errs.* error", err) + } + if p.Category != errs.CategoryInternal { + t.Fatalf("Category = %v, want CategoryInternal", p.Category) + } + if p.Subtype != errs.SubtypeUnknown { + t.Fatalf("Subtype = %v, want SubtypeUnknown", p.Subtype) + } + if p.Hint != "presentation was created" { + t.Fatalf("Hint = %q, want the progress hint", p.Hint) + } + if !errors.Is(err, cause) { + t.Fatalf("fallback must preserve the original cause via WithCause") + } + }) +} diff --git a/shortcuts/slides/slides_media_upload.go b/shortcuts/slides/slides_media_upload.go index ebf086527..8834cd0a9 100644 --- a/shortcuts/slides/slides_media_upload.go +++ b/shortcuts/slides/slides_media_upload.go @@ -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", "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 diff --git a/shortcuts/slides/slides_media_upload_test.go b/shortcuts/slides/slides_media_upload_test.go index c78d9ea14..a46205bb4 100644 --- a/shortcuts/slides/slides_media_upload_test.go +++ b/shortcuts/slides/slides_media_upload_test.go @@ -6,6 +6,7 @@ package slides import ( "bytes" "encoding/json" + "errors" "mime" "mime/multipart" "os" @@ -14,6 +15,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" @@ -215,6 +217,14 @@ func TestSlidesMediaUploadFileNotFound(t *testing.T) { if !strings.Contains(err.Error(), "file not found") && !strings.Contains(err.Error(), "no such file") { t.Fatalf("err = %v, want file-not-found error", err) } + + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } } // TestSlidesMediaUploadInvalidPresentation verifies validation rejects a bad ref. diff --git a/shortcuts/slides/slides_replace_slide.go b/shortcuts/slides/slides_replace_slide.go index c576ce987..a69107288 100644 --- a/shortcuts/slides/slides_replace_slide.go +++ b/shortcuts/slides/slides_replace_slide.go @@ -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").WithParam("--slide-id") } 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").WithParam("--parts") } 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).WithParam("--parts").WithCause(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).WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") } 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").WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") } 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).WithParam("--parts") case "": - return common.FlagErrorf("--parts[%d].action is required", i) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action is required", i).WithParam("--parts") 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).WithParam("--parts") } } 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).WithParam("--parts").WithCause(err) } fixed = ensureShapeHasContent(fixed) m["block_id"] = *p.BlockID diff --git a/shortcuts/slides/slides_replace_slide_test.go b/shortcuts/slides/slides_replace_slide_test.go index 7ed4d9155..a4c37db39 100644 --- a/shortcuts/slides/slides_replace_slide_test.go +++ b/shortcuts/slides/slides_replace_slide_test.go @@ -10,9 +10,9 @@ 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" ) // TestReplaceSlideBlockReplaceInjectsID is the core regression: users write @@ -631,15 +631,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 +670,64 @@ 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) + } +} + +// TestReplaceSlideValidationParam locks the structured Param on every +// +replace-slide validation error, so callers route on the typed field +// instead of parsing the message. Guards against a regression where the flag +// tag is dropped from any of the --slide-id / --parts validation branches. +func TestReplaceSlideValidationParam(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + slideID string + parts string + wantParam string + }{ + {"slide-id empty", "", `[{"action":"block_insert","insertion":""}]`, "--slide-id"}, + {"parts whitespace-only", "s", " ", "--parts"}, + {"parts invalid JSON", "s", "not-json", "--parts"}, + {"parts non-string field", "s", `[{"action":123}]`, "--parts"}, + {"parts empty array", "s", `[]`, "--parts"}, + {"parts missing required field", "s", `[{"action":"block_insert"}]`, "--parts"}, + {"parts str_replace rejected", "s", `[{"action":"str_replace","pattern":"a","replacement":"b"}]`, "--parts"}, + {"parts unknown action", "s", `[{"action":"nuke","block_id":"b"}]`, "--parts"}, + {"parts replacement without root", "s", `[{"action":"block_replace","block_id":"b","replacement":"plain text"}]`, "--parts"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{ + "+replace-slide", + "--presentation", "pres_abc", + "--slide-id", tt.slideID, + "--parts", tt.parts, + "--as", "user", + }) + + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != tt.wantParam { + t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam) + } + }) } } From 7cc0b49603154d0ab29a4be928c314d009ce9bf0 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Wed, 10 Jun 2026 14:21:34 +0800 Subject: [PATCH 06/48] docs(skills): warn about @file absolute path restriction in lark-doc skills (#1375) --- skills/lark-doc/references/lark-doc-md.md | 1 + skills/lark-doc/references/style/lark-doc-create-workflow.md | 1 + 2 files changed, 2 insertions(+) diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md index 88e4e5425..f6a72d436 100644 --- a/skills/lark-doc/references/lark-doc-md.md +++ b/skills/lark-doc/references/lark-doc-md.md @@ -49,6 +49,7 @@ ## Shell 传参 - **首选文件传参**:`--content` 支持 `@path/to/file.md`(读文件)和 `-`(读 stdin),彻底绕开 shell 转义;多行、含特殊字符、长文本强烈推荐。字面量以 `@` 开头时用 `@@` 转义(`--pattern` 不支持 `@file`) +- **⚠️ `@file` 路径限制**:`@file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下(如 `./_content.md`),用完自行清理。 - **默认用单引号 `'...'`**:完全字面量,`$`、`` ` ``、`\`、`>`、`\` 等全部原样保留 - **双引号 `"..."`**:会展开 `$变量`、反引号和 `$(...)` 命令替换,`\` 仍参与转义,易踩坑 - **`$'...'` ANSI-C 引号**:按 C 转义解析,`\n`=换行、`\\`=单个 `\`;**zsh 下未知转义(如 `\<`)的 `\` 会被吞**,要保留字面 `\` 必须写 `\\`。只在确实需要 `\n`/`\t` 时用 diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md index 1800a369b..62065b881 100644 --- a/skills/lark-doc/references/style/lark-doc-create-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-create-workflow.md @@ -23,6 +23,7 @@ 3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `` + 各级标题 + 每节一句占位摘要 - ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。 - 完整内容留到第二波,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。 + - ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。 ### 第二波 — 内容撰写(并行 Agent) From 0d20a02050ccb9b5bd1bbba02782a29418443e1c Mon Sep 17 00:00:00 2001 From: zhangjun-bytedance Date: Wed, 10 Jun 2026 14:41:44 +0800 Subject: [PATCH 07/48] feat: replace words for transcript (#1372) --- .../minutes/minutes_speaker_replace_test.go | 14 +- shortcuts/minutes/minutes_summary.go | 73 +++ .../minutes/minutes_summary_todo_test.go | 415 ++++++++++++++++++ shortcuts/minutes/minutes_todo.go | 293 +++++++++++++ shortcuts/minutes/minutes_update_test.go | 2 +- shortcuts/minutes/minutes_word_replace.go | 156 +++++++ .../minutes/minutes_word_replace_test.go | 283 ++++++++++++ shortcuts/minutes/shortcuts.go | 3 + skills/lark-minutes/SKILL.md | 85 +++- .../references/lark-minutes-summary.md | 122 +++++ .../references/lark-minutes-todo.md | 138 ++++++ skills/lark-task/SKILL.md | 6 +- skills/lark-vc/references/lark-vc-notes.md | 2 +- .../minutes/minutes_word_replace_test.go | 39 ++ 14 files changed, 1612 insertions(+), 19 deletions(-) create mode 100644 shortcuts/minutes/minutes_summary.go create mode 100644 shortcuts/minutes/minutes_summary_todo_test.go create mode 100644 shortcuts/minutes/minutes_todo.go create mode 100644 shortcuts/minutes/minutes_word_replace.go create mode 100644 shortcuts/minutes/minutes_word_replace_test.go create mode 100644 skills/lark-minutes/references/lark-minutes-summary.md create mode 100644 skills/lark-minutes/references/lark-minutes-todo.md create mode 100644 tests/cli_e2e/minutes/minutes_word_replace_test.go diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go index d2b5fa5ee..78944b2d4 100644 --- a/shortcuts/minutes/minutes_speaker_replace_test.go +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -33,27 +33,27 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) { }, { name: "missing from", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--to-user-id", "ou_b", "--as", "user"}, wantErr: "required flag(s) \"from-user-id\" not set", }, { name: "missing to", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_a", "--as", "user"}, wantErr: "required flag(s) \"to-user-id\" not set", }, { name: "invalid from prefix", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, wantErr: "invalid user ID format", }, { name: "invalid to prefix", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"}, wantErr: "invalid user ID format", }, { name: "from equals to", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, wantErr: "must be different", }, } @@ -87,12 +87,12 @@ func TestMinutesSpeakerReplace_ValidateTyped(t *testing.T) { }{ { name: "invalid from prefix", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"}, wantParam: "--from-user-id", }, { name: "from equals to", - args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, + args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"}, wantParam: "--to-user-id", }, } diff --git a/shortcuts/minutes/minutes_summary.go b/shortcuts/minutes/minutes_summary.go new file mode 100644 index 000000000..f8277eb25 --- /dev/null +++ b/shortcuts/minutes/minutes_summary.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const minutesSummaryMarkdownTip = "Summary accepts any text; unsupported Markdown is saved but may display as literal raw text in Minutes. For best rendering, prefer plain text, line breaks, headings (#, ##, ###), bold (**text**), and lists (-, *, or 1.)." + +// MinutesSummary replaces the AI summary of a minute. +var MinutesSummary = common.Shortcut{ + Service: "minutes", + Command: "+summary", + Description: "Replace the AI summary of a minute", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + {Name: "summary", Desc: "replacement summary text (Markdown subset renders best in Minutes)", Required: true, Input: []string{common.File, common.Stdin}}, + }, + Tips: []string{ + minutesSummaryMarkdownTip, + "Use `lark-cli vc +notes --minute-tokens ` to read the current summary before replacing it.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := runtime.Str("minute-token") + if minuteToken == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") + } + summary := strings.TrimSpace(runtime.Str("summary")) + if summary == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--summary is required").WithParam("--summary") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/summary", validate.EncodePathSegment(runtime.Str("minute-token")))). + Body(map[string]interface{}{"summary": ""}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := runtime.Str("minute-token") + summary := strings.TrimSpace(runtime.Str("summary")) + + path := fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/summary", validate.EncodePathSegment(minuteToken)) + body := map[string]interface{}{ + "summary": summary, + } + if _, err := runtime.CallAPITyped(http.MethodPut, path, nil, body); err != nil { + return err + } + + runtime.OutFormat(map[string]interface{}{ + "minute_token": minuteToken, + "updated": true, + }, nil, nil) + return nil + }, +} diff --git a/shortcuts/minutes/minutes_summary_todo_test.go b/shortcuts/minutes/minutes_summary_todo_test.go new file mode 100644 index 000000000..0ebd3d297 --- /dev/null +++ b/shortcuts/minutes/minutes_summary_todo_test.go @@ -0,0 +1,415 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +const minutesSummaryTodoTestToken = "obcnexampleminute" + +func todoStub(token string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/minutes/v1/minutes/" + token + "/todo", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + } +} + +func firstTodoItem(t *testing.T, raw []byte) map[string]any { + t.Helper() + items := todoItems(t, raw) + if len(items) != 1 { + t.Fatalf("todo_items: want 1 item, got %d (%v)", len(items), items) + } + return items[0] +} + +func todoItems(t *testing.T, raw []byte) []map[string]any { + t.Helper() + if len(raw) == 0 { + t.Fatal("request body was not captured") + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("failed to parse captured body: %v", err) + } + rawItems, _ := body["todo_items"].([]any) + items := make([]map[string]any, 0, len(rawItems)) + for _, rawItem := range rawItems { + item, _ := rawItem.(map[string]any) + items = append(items, item) + } + return items +} + +func TestMinutesSummary_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesSummary, []string{ + "+summary", + "--minute-token", minutesSummaryTodoTestToken, + "--summary", "**Weekly sync**\n- follow up", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "PUT") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSummaryTodoTestToken+"/summary") { + t.Fatalf("dry-run output = %q", out) + } +} + +func TestMinutesTodo_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "- finish deck", + "--is-done", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "POST") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSummaryTodoTestToken+"/todo") { + t.Fatalf("dry-run output = %q", out) + } + if !strings.Contains(out, "todo_items") { + t.Fatalf("dry-run output should contain todo_items, got %q", out) + } + if !strings.Contains(out, "operation") || !strings.Contains(out, "add") { + t.Fatalf("dry-run output should contain the operation, got %q", out) + } +} + +func TestMinutesTodo_RequiresIsDone(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "finish deck", + "--as", "user", + }, f, stderr) + if err == nil { + t.Fatal("expected validation error for missing --is-done") + } + if !strings.Contains(err.Error(), "is-done") { + t.Fatalf("error = %q, want message mentioning is-done", err.Error()) + } +} + +func TestMinutesTodo_RequiresOperation(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--todo", "finish deck", + "--is-done", + "--as", "user", + }, f, stderr) + if err == nil { + t.Fatal("expected validation error for missing --operation") + } + if !strings.Contains(err.Error(), "operation") { + t.Fatalf("error = %q, want message mentioning operation", err.Error()) + } +} + +func TestMinutesTodo_RejectsUnknownOperation(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", + "--minute-token", minutesSummaryTodoTestToken, + "--operation", "archive", + "--todo", "finish deck", + "--is-done", + "--as", "user", + }, f, stderr) + if err == nil { + t.Fatal("expected validation error for unknown --operation value") + } +} + +func TestMinutesTodo_Add_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "finish deck", "--is-done=false", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + item := firstTodoItem(t, stub.CapturedBody) + if item["operation"] != "add" { + t.Errorf("operation = %v, want add", item["operation"]) + } + if item["content"] != "finish deck" { + t.Errorf("content = %v, want finish deck", item["content"]) + } + if item["is_done"] != false { + t.Errorf("is_done = %v, want false", item["is_done"]) + } + if _, ok := item["todo_id"]; ok { + t.Errorf("add should not send todo_id, got %v", item["todo_id"]) + } + if !strings.Contains(stdout.String(), "add") { + t.Errorf("output should report add operation, got %q", stdout.String()) + } +} + +func TestMinutesTodo_Update_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "update", + "--todo-id", "99", "--todo", "updated deck", "--is-done", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + item := firstTodoItem(t, stub.CapturedBody) + if item["operation"] != "update" { + t.Errorf("operation = %v, want update", item["operation"]) + } + if item["todo_id"] != "99" { + t.Errorf("todo_id = %v, want 99", item["todo_id"]) + } + if item["content"] != "updated deck" { + t.Errorf("content = %v, want updated deck", item["content"]) + } + if item["is_done"] != true { + t.Errorf("is_done = %v, want true", item["is_done"]) + } +} + +func TestMinutesTodo_Delete_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "delete", + "--todo-id", "88", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + item := firstTodoItem(t, stub.CapturedBody) + if item["operation"] != "delete" { + t.Errorf("operation = %v, want delete", item["operation"]) + } + if item["todo_id"] != "88" { + t.Errorf("todo_id = %v, want 88", item["todo_id"]) + } + if _, ok := item["content"]; ok { + t.Errorf("delete should not send content, got %v", item["content"]) + } + if _, ok := item["is_done"]; ok { + t.Errorf("delete should not send is_done, got %v", item["is_done"]) + } + // the todo id must never be surfaced to the user in the command output + if strings.Contains(stdout.String(), "88") { + t.Errorf("output must not expose the todo id, got %q", stdout.String()) + } +} + +func TestMinutesTodo_DeleteRejectsIsDone(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "delete", + "--todo-id", "88", "--is-done", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --is-done is used to delete") + } +} + +func TestMinutesTodo_AddRejectsTodoID(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo-id", "88", "--todo", "finish deck", "--is-done", "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --todo-id is used with operation add") + } +} + +func TestMinutesTodo_BatchAdd_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--todos", `[{"operation":"add","content":"晚上好1","is_done":true},{"operation":"add","content":"晚上好2","is_done":false}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items := todoItems(t, stub.CapturedBody) + if len(items) != 2 { + t.Fatalf("todo_items: want 2 items, got %d", len(items)) + } + if items[0]["content"] != "晚上好1" || items[0]["is_done"] != true { + t.Errorf("items[0] = %v", items[0]) + } + if items[1]["content"] != "晚上好2" || items[1]["is_done"] != false { + t.Errorf("items[1] = %v", items[1]) + } +} + +func TestMinutesTodo_BatchMixed_RequestBody(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + stub := todoStub(minutesSummaryTodoTestToken) + reg.Register(stub) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--todos", `[{"operation":"add","content":"new item","is_done":false},{"operation":"update","todo_id":"99","content":"updated","is_done":true},{"operation":"delete","todo_id":"88"}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items := todoItems(t, stub.CapturedBody) + if len(items) != 3 { + t.Fatalf("todo_items: want 3 items, got %d", len(items)) + } + if items[0]["operation"] != "add" || items[1]["operation"] != "update" || items[2]["operation"] != "delete" { + t.Errorf("operations order = %v, %v, %v", items[0]["operation"], items[1]["operation"], items[2]["operation"]) + } + if items[2]["todo_id"] != "88" { + t.Errorf("delete todo_id = %v", items[2]["todo_id"]) + } + if !strings.Contains(stdout.String(), `"count"`) { + t.Errorf("output should include count, got %q", stdout.String()) + } +} + +func TestMinutesTodo_BatchRejectsSingleFlags(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--todos", `[{"operation":"add","content":"a","is_done":false}]`, + "--operation", "add", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --todos is mixed with --operation") + } +} + +func TestMinutesTodo_RequiresAnyInput(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected validation error when --operation is not provided") + } +} + +func TestMinutesTodo_NoEditPermission(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPost, + URL: "/open-apis/minutes/v1/minutes/" + minutesSummaryTodoTestToken + "/todo", + Body: map[string]interface{}{ + "code": minutesTodoNoEditPermissionCode, + "msg": "permission deny", + }, + }) + + err := mountAndRun(t, MinutesTodo, []string{ + "+todo", "--minute-token", minutesSummaryTodoTestToken, + "--operation", "add", + "--todo", "finish deck", "--is-done=false", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypePermissionDenied { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied) + } + if !strings.Contains(p.Message, "No edit permission") { + t.Errorf("message should be friendly, got: %s", p.Message) + } + if !strings.Contains(p.Message, minutesSummaryTodoTestToken) { + t.Errorf("message should include minute token, got: %s", p.Message) + } + if !strings.Contains(p.Hint, "edit permission") { + t.Errorf("hint should mention edit permission, got: %s", p.Hint) + } +} + +func TestMinutesSummaryAndTodo_HelpMetadata(t *testing.T) { + for _, tip := range MinutesSummary.Tips { + if strings.Contains(tip, "raw text") { + return + } + } + t.Fatal("MinutesSummary tips should mention unsupported markdown display behavior") +} diff --git a/shortcuts/minutes/minutes_todo.go b/shortcuts/minutes/minutes_todo.go new file mode 100644 index 000000000..05888c333 --- /dev/null +++ b/shortcuts/minutes/minutes_todo.go @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const minutesTodoNoEditPermissionCode = 40005 + +// minuteTodoOp describes a resolved todo_items entry derived from flags or JSON. +type minuteTodoOp struct { + operation string // add | update | delete + item map[string]interface{} // the todo_items entry sent to the API +} + +// minuteTodoSpec is the JSON shape for --todos batch input. +type minuteTodoSpec struct { + Operation string `json:"operation"` + Content string `json:"content"` + IsDone *bool `json:"is_done"` + TodoID string `json:"todo_id"` +} + +// MinutesTodo adds, updates, or deletes todo item(s) on a minute. +var MinutesTodo = common.Shortcut{ + Service: "minutes", + Command: "+todo", + Description: "Add, update, or delete todo item(s) on a minute", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token (required)", Required: true}, + {Name: "operation", Desc: "operation for a single todo (required unless --todos)", Enum: []string{"add", "update", "delete"}}, + {Name: "todo", Desc: "todo plain-text content; required by single add/update", Input: []string{common.File, common.Stdin}}, + {Name: "is-done", Type: "bool", Desc: "completion flag; required by single add/update"}, + {Name: "todo-id", Desc: "id of an existing todo; required by single update/delete"}, + { + Name: "todos", + Desc: `batch todo_items JSON array; each item has operation add|update|delete (supports @file / @-)`, + Input: []string{common.File, common.Stdin}, + }, + }, + Tips: []string{ + "Single todo: `--operation add --todo \"...\" --is-done=false`.", + "Batch: `--todos '[{\"operation\":\"add\",\"content\":\"...\",\"is_done\":false}, ...]'` or `--todos @todos.json`.", + "Batch can mix add, update, and delete in one request; array order is preserved in the API body.", + "Update: `--operation update --todo-id --todo \"...\" --is-done`.", + "Delete: `--operation delete --todo-id `.", + "`content` is plain text only; markdown formatting is not supported.", + "Use `lark-cli vc +notes --minute-tokens ` to read current todos before writing.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := runtime.Str("minute-token") + if minuteToken == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") + } + if _, err := resolveMinuteTodoOps(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + api := common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/todo", validate.EncodePathSegment(runtime.Str("minute-token")))) + ops, err := resolveMinuteTodoOps(runtime) + if err != nil { + return api.Body(map[string]interface{}{ + "todo_items": "", + }) + } + return api.Body(map[string]interface{}{ + "todo_items": todoItemsFromOps(ops), + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := runtime.Str("minute-token") + ops, err := resolveMinuteTodoOps(runtime) + if err != nil { + return err + } + + path := fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/todo", validate.EncodePathSegment(minuteToken)) + body := map[string]interface{}{ + "todo_items": todoItemsFromOps(ops), + } + if _, err := runtime.CallAPITyped(http.MethodPost, path, nil, body); err != nil { + return minutesTodoError(err, minuteToken) + } + + out := map[string]interface{}{ + "minute_token": minuteToken, + "count": len(ops), + "updated": true, + } + if len(ops) == 1 { + out["operation"] = ops[0].operation + } + runtime.OutFormat(out, nil, nil) + return nil + }, +} + +func todoItemsFromOps(ops []minuteTodoOp) []interface{} { + items := make([]interface{}, len(ops)) + for i, op := range ops { + items[i] = op.item + } + return items +} + +// resolveMinuteTodoOps builds todo_items from either --todos (batch) or single-item flags. +func resolveMinuteTodoOps(runtime *common.RuntimeContext) ([]minuteTodoOp, error) { + hasTodos := strings.TrimSpace(runtime.Str("todos")) != "" + hasSingle := runtime.Changed("operation") || runtime.Changed("todo") || + runtime.Changed("is-done") || runtime.Changed("todo-id") + + if hasTodos && hasSingle { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "use either --todos for batch or single-item flags (--operation, --todo, --is-done, --todo-id), not both").WithParam("--todos") + } + if hasTodos { + return resolveMinuteTodoBatch(runtime.Str("todos")) + } + op, err := resolveMinuteTodoSingle(runtime) + if err != nil { + return nil, err + } + return []minuteTodoOp{*op}, nil +} + +func resolveMinuteTodoBatch(raw string) ([]minuteTodoOp, error) { + specs, err := parseMinuteTodoSpecs(raw) + if err != nil { + return nil, err + } + if len(specs) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--todos must contain at least one todo item").WithParam("--todos") + } + ops := make([]minuteTodoOp, 0, len(specs)) + for i, spec := range specs { + item, err := buildMinuteTodoItem(spec) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "todos[%d]: %s", i, err).WithParam("--todos").WithCause(err) + } + ops = append(ops, minuteTodoOp{ + operation: strings.TrimSpace(spec.Operation), + item: item, + }) + } + return ops, nil +} + +func parseMinuteTodoSpecs(raw string) ([]minuteTodoSpec, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--todos: value is empty").WithParam("--todos") + } + var specs []minuteTodoSpec + if err := json.Unmarshal([]byte(raw), &specs); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--todos: invalid JSON array: %s", err).WithParam("--todos").WithCause(err) + } + return specs, nil +} + +func resolveMinuteTodoSingle(runtime *common.RuntimeContext) (*minuteTodoOp, error) { + operation := strings.TrimSpace(runtime.Str("operation")) + todo := strings.TrimSpace(runtime.Str("todo")) + todoID := strings.TrimSpace(runtime.Str("todo-id")) + hasTodo := todo != "" + hasTodoID := todoID != "" + hasIsDone := runtime.Changed("is-done") + + if operation == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--operation is required for single-item mode (or use --todos for batch)").WithParam("--operation") + } + + spec := minuteTodoSpec{Operation: operation} + switch operation { + case "add": + if !hasTodo || !hasIsDone { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" requires --todo and --is-done").WithParam("--todo") + } + if hasTodoID { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" does not accept --todo-id (it creates a new todo)").WithParam("--todo-id") + } + done := runtime.Bool("is-done") + spec.Content = todo + spec.IsDone = &done + case "update": + if !hasTodoID || !hasTodo || !hasIsDone { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires --todo-id, --todo and --is-done").WithParam("--todo-id") + } + done := runtime.Bool("is-done") + spec.TodoID = todoID + spec.Content = todo + spec.IsDone = &done + case "delete": + if !hasTodoID { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" requires --todo-id").WithParam("--todo-id") + } + if hasTodo || hasIsDone { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" only accepts --todo-id (omit --todo and --is-done)").WithParam("--todo-id") + } + spec.TodoID = todoID + default: + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--operation is required, allowed: add, update, delete").WithParam("--operation") + } + + item, err := buildMinuteTodoItem(spec) + if err != nil { + return nil, err + } + return &minuteTodoOp{operation: operation, item: item}, nil +} + +func buildMinuteTodoItem(spec minuteTodoSpec) (map[string]interface{}, error) { + operation := strings.TrimSpace(spec.Operation) + if operation == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation is required") + } + if operation != "add" && operation != "update" && operation != "delete" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation %q is invalid, allowed: add, update, delete", operation) + } + + content := strings.TrimSpace(spec.Content) + todoID := strings.TrimSpace(spec.TodoID) + item := map[string]interface{}{"operation": operation} + + switch operation { + case "add": + if todoID != "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" does not accept todo_id") + } + if content == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" requires content") + } + if spec.IsDone == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"add\" requires is_done") + } + item["content"] = content + item["is_done"] = *spec.IsDone + case "update": + if todoID == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires todo_id") + } + if content == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires content") + } + if spec.IsDone == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"update\" requires is_done") + } + item["todo_id"] = todoID + item["content"] = content + item["is_done"] = *spec.IsDone + case "delete": + if todoID == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" requires todo_id") + } + if content != "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" must not include content") + } + if spec.IsDone != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "operation \"delete\" must not include is_done") + } + item["todo_id"] = todoID + } + return item, nil +} + +func minutesTodoError(err error, minuteToken string) error { + p, ok := errs.ProblemOf(err) + if !ok || p.Code != minutesTodoNoEditPermissionCode { + return err + } + p.Subtype = errs.SubtypePermissionDenied + p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update todos.", minuteToken) + p.Hint = "Ask the minute owner for minute edit permission" + return err +} diff --git a/shortcuts/minutes/minutes_update_test.go b/shortcuts/minutes/minutes_update_test.go index 8c7d5e00d..222649367 100644 --- a/shortcuts/minutes/minutes_update_test.go +++ b/shortcuts/minutes/minutes_update_test.go @@ -32,7 +32,7 @@ func TestMinutesUpdate_Validate(t *testing.T) { }, { name: "missing topic", - args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"}, + args: []string{"+update", "--minute-token", minutesUpdateTestToken, "--as", "user"}, wantErr: "required flag(s) \"topic\" not set", }, } diff --git a/shortcuts/minutes/minutes_word_replace.go b/shortcuts/minutes/minutes_word_replace.go new file mode 100644 index 000000000..fc079872d --- /dev/null +++ b/shortcuts/minutes/minutes_word_replace.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + minutesWordReplaceNoEditPermission = 40005 + minutesWordReplaceOthersEditing = 40110 + minutesWordReplaceInvalidParams = 40001 +) + +type transcriptWordReplace struct { + SourceWord string `json:"source_word"` + TargetWord string `json:"target_word"` +} + +// MinutesWordReplace batch-replaces words in a minute's transcript. +var MinutesWordReplace = common.Shortcut{ + Service: "minutes", + Command: "+word-replace", + Description: "Batch replace words in a minute's transcript", + Risk: "write", + Scopes: []string{"minutes:minutes:update"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-token", Desc: "minute token", Required: true}, + { + Name: "replace-words", + Desc: `JSON array of replacements, e.g. [{"source_word":"old","target_word":"new"}]`, + Required: true, + Input: []string{common.File, common.Stdin}, + }, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + if minuteToken == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token") + } + if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") + } + if _, err := parseReplaceWords(runtime.Str("replace-words")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + replaceWords, _ := parseReplaceWords(runtime.Str("replace-words")) + return common.NewDryRunAPI(). + PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/word", validate.EncodePathSegment(minuteToken))). + Body(map[string]interface{}{ + "minute_token": minuteToken, + "replace_words": replaceWords, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + minuteToken := strings.TrimSpace(runtime.Str("minute-token")) + replaceWords, err := parseReplaceWords(runtime.Str("replace-words")) + if err != nil { + return err + } + + body := map[string]interface{}{ + "minute_token": minuteToken, + "replace_words": replaceWords, + } + + _, err = runtime.CallAPITyped(http.MethodPut, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/word", validate.EncodePathSegment(minuteToken)), + nil, body) + if err != nil { + return minutesWordReplaceError(err, minuteToken) + } + + outData := map[string]interface{}{ + "minute_token": minuteToken, + "replace_words": replaceWords, + } + + runtime.OutFormat(outData, nil, nil) + return nil + }, +} + +func parseReplaceWords(raw string) ([]map[string]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: value is required").WithParam("--replace-words") + } + + var items []transcriptWordReplace + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: must be a JSON array of {source_word,target_word} objects: %v", err).WithParam("--replace-words").WithCause(err) + } + if len(items) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: must include at least one replacement").WithParam("--replace-words") + } + + replaceWords := make([]map[string]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for i, item := range items { + sourceWord := strings.TrimSpace(item.SourceWord) + if sourceWord == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: item %d: source_word is required", i).WithParam("--replace-words") + } + if _, exists := seen[sourceWord]; exists { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--replace-words: duplicate source_word %q", sourceWord).WithParam("--replace-words") + } + seen[sourceWord] = struct{}{} + replaceWords = append(replaceWords, map[string]string{ + "source_word": sourceWord, + "target_word": item.TargetWord, + }) + } + return replaceWords, nil +} + +func minutesWordReplaceError(err error, minuteToken string) error { + p, ok := errs.ProblemOf(err) + if !ok { + return err + } + + switch p.Code { + case minutesWordReplaceNoEditPermission: + p.Subtype = errs.SubtypePermissionDenied + p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace transcript words.", minuteToken) + p.Hint = "Ask the minute owner for minute edit permission" + case minutesWordReplaceOthersEditing: + p.Subtype = errs.SubtypeConflict + p.Message = fmt.Sprintf("Minute %q transcript is being edited by someone else.", minuteToken) + p.Hint = "Wait until the other editor finishes, then retry" + case minutesWordReplaceInvalidParams: + if strings.Contains(strings.ToLower(p.Message), "not found in transcript") { + p.Subtype = errs.SubtypeNotFound + p.Message = fmt.Sprintf("None of the source words were found in minute %q transcript; nothing was replaced.", minuteToken) + p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use vc +notes to read it), then retry" + } + } + + return err +} diff --git a/shortcuts/minutes/minutes_word_replace_test.go b/shortcuts/minutes/minutes_word_replace_test.go new file mode 100644 index 000000000..589ca2e8c --- /dev/null +++ b/shortcuts/minutes/minutes_word_replace_test.go @@ -0,0 +1,283 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/spf13/cobra" +) + +const minutesWordReplaceTestToken = "obcnexampleminute" + +func TestMinutesWordReplace_Validate(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing minute token", + args: []string{"+word-replace", "--replace-words", `[{"source_word":"a","target_word":"b"}]`, "--as", "user"}, + wantErr: "required flag(s) \"minute-token\" not set", + }, + { + name: "missing replace words", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--as", "user"}, + wantErr: "required flag(s) \"replace-words\" not set", + }, + { + name: "invalid json", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--replace-words", "not-json", "--as", "user"}, + wantErr: "JSON array", + }, + { + name: "empty source word", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--replace-words", `[{"source_word":"","target_word":"b"}]`, "--as", "user"}, + wantErr: "source_word is required", + }, + { + name: "duplicate source word", + args: []string{"+word-replace", "--minute-token", minutesWordReplaceTestToken, "--replace-words", `[{"source_word":"a","target_word":"b"},{"source_word":"a","target_word":"c"}]`, "--as", "user"}, + wantErr: "duplicate source_word", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "minutes"} + MinutesWordReplace.Mount(parent, f) + parent.SetArgs(tt.args) + parent.SilenceErrors = true + parent.SilenceUsage = true + err := parent.Execute() + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error()) + } + }) + } +} + +func TestMinutesWordReplace_DryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "PUT") { + t.Errorf("expected PUT method, got:\n%s", out) + } + if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesWordReplaceTestToken+"/transcript/word") { + t.Errorf("expected word endpoint, got:\n%s", out) + } + if !strings.Contains(out, "foo") || !strings.Contains(out, "bar") { + t.Errorf("expected replace_words in body, got:\n%s", out) + } +} + +func TestMinutesWordReplace_Execute(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope struct { + Data struct { + MinuteToken string `json:"minute_token"` + ReplaceWords []struct { + SourceWord string `json:"source_word"` + TargetWord string `json:"target_word"` + } `json:"replace_words"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Data.MinuteToken != minutesWordReplaceTestToken { + t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesWordReplaceTestToken) + } + if len(envelope.Data.ReplaceWords) != 1 || envelope.Data.ReplaceWords[0].SourceWord != "foo" || envelope.Data.ReplaceWords[0].TargetWord != "bar" { + t.Errorf("data.replace_words = %#v, want foo->bar", envelope.Data.ReplaceWords) + } +} + +func TestMinutesWordReplace_NoEditPermission(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceNoEditPermission, + "msg": "permission deny", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected no-edit-permission error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypePermissionDenied { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied) + } +} + +func TestMinutesWordReplace_OthersAreEditing(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceOthersEditing, + "msg": "others are editing", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected others-are-editing error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeConflict { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeConflict) + } +} + +func TestMinutesWordReplace_WordsNotFound(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceInvalidParams, + "msg": "replace words not found in transcript", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected words-not-found error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeNotFound { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeNotFound) + } + if !strings.Contains(p.Message, minutesWordReplaceTestToken) { + t.Errorf("message should include minute token, got: %s", p.Message) + } + if !strings.Contains(p.Hint, "source_word") { + t.Errorf("hint should mention source_word, got: %s", p.Hint) + } +} + +// A generic 40001 without the transcript marker must NOT be rewritten as +// words_not_found; it should surface as the original invalid-params error. +func TestMinutesWordReplace_GenericInvalidParamsNotRewritten(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesWordReplaceTestToken + "/transcript/word", + Body: map[string]interface{}{ + "code": minutesWordReplaceInvalidParams, + "msg": "Invalid Params", + }, + }) + + err := mountAndRun(t, MinutesWordReplace, []string{ + "+word-replace", + "--minute-token", minutesWordReplaceTestToken, + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--format", "json", "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected invalid-params error, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed errs.*, got %T: %v", err, err) + } + if p.Subtype == errs.SubtypeNotFound && strings.Contains(p.Message, "None of the source words were found") { + t.Fatalf("generic 40001 must not be rewritten as not_found, got subtype=%q message=%q", p.Subtype, p.Message) + } +} diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 75d0a70b9..283eca7f8 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -12,6 +12,9 @@ func Shortcuts() []common.Shortcut { MinutesDownload, MinutesUpload, MinutesUpdate, + MinutesSummary, + MinutesTodo, MinutesSpeakerReplace, + MinutesWordReplace, } } diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index ac464ee29..f4cbfb4d8 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -82,13 +82,12 @@ metadata: > **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 -### 4. 获取妙记的逐字稿、总结、待办、章节 +### 4. 读取妙记的逐字稿、总结、待办、章节(只读) -1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。 -2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。 -3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。 -4. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",也支持;此时应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。 -5. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。 +1. 当用户要**查看 / 读取**"这个妙记的逐字稿""总结""待办""章节"时,使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 +2. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。 +3. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。 +4. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。 ```bash # 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节) @@ -96,6 +95,7 @@ lark-cli vc +notes --minute-tokens ``` > **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供 +> **读 vs 写**:`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。 ### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) @@ -110,6 +110,46 @@ lark-cli vc +notes --minute-tokens > > **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。 +### 6. 编辑妙记的 AI 待办与 AI 总结(写入) + +当用户要在**某条妙记内**操作 AI 待办或 AI 总结时使用本节。**不是**飞书任务(Task)清单里的待办。 + +**触发信号(任一命中即走本 skill,禁止走 lark-task)**: + +- "在(某条)妙记里新建 / 添加 / 修改 / 删除待办" +- "把妙记 A 的待办改成已完成 / 未完成" +- "妙记里的任务1 / 任务2"(上下文已明确是妙记) +- 已给出 `minute_token` 或妙记 URL,且要改待办 / 总结 + +**妙记 AI 待办 vs 飞书任务 Task**: + +| 用户意图 | 正确命令 | 错误命令 | +|---------|---------|---------| +| 妙记里加待办 | `minutes +todo --operation add` 或 `--todos '[...]'` | `task +create` / `task tasklists list` | +| 妙记里改待办 | `minutes +todo --operation update --todo-id ...` | `task +update` | +| 妙记里删待办 | `minutes +todo --operation delete --todo-id ...` | `task tasks delete` | +| 我的任务清单 | — | 走 [lark-task](../lark-task/SKILL.md) | + +**新建多条待办**:优先用 `--todos` 一次提交;单条则用多次 `--operation add`: + +```bash +# 批量:任务1 已完成 + 任务2 未完成 +lark-cli minutes +todo --minute-token --as user --todos '[ + {"operation":"add","content":"晚上好1","is_done":true}, + {"operation":"add","content":"晚上好2","is_done":false} +]' +``` + +**更新 / 删除前**:先用 `vc +notes --minute-tokens ` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。 + +**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。 + +**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `vc +notes --minute-tokens ` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。 + +**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。 + +> 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。 + ## 资源关系 ```text @@ -118,24 +158,51 @@ Minutes (妙记) ← minute_token 标识 └── MediaFile (音频/视频文件) → minutes +download ``` -> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件、上传音视频生成妙记**。 +> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。 > > **路由规则**: > > - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search` > - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill > - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token` -> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要的是逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens` +> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要**读取**逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens` > - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准 > - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果 > - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限 > - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get` > - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download` -> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) +> - 用户要**读取**"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) +> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**(含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md),**禁止**走 lark-task +> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md) > - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` > - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens` > - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` > - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` +> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace` + +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | +| -------------------------------------------------- | --------------------------------------------------------------- | +| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range | +| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | +| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute | +| [`+update`](references/lark-minutes-update.md) | Update a minute's title | +| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) | +| [`+summary`](references/lark-minutes-summary.md) | Replace the full AI summary text of a minute | +| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete **AI todo(s) inside a minute** (single or batch via `--todos`; not Feishu Task) | + +- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。 +- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。 +- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。 +- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。 +- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID,不支持姓名)。 +- 使用 `+summary` 命令时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md),了解全文替换参数。 +- 使用 `+todo` 命令时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md),了解单条与 `--todos` 批量模式;**不要**用 lark-task。 + + ## API Resources diff --git a/skills/lark-minutes/references/lark-minutes-summary.md b/skills/lark-minutes/references/lark-minutes-summary.md new file mode 100644 index 000000000..e7bc71344 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-summary.md @@ -0,0 +1,122 @@ +# minutes +summary + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +替换妙记的 AI 总结内容。写操作,会覆盖当前总结。 + +本 skill 对应 shortcut:`lark-cli minutes +summary`(调用 `PUT /open-apis/minutes/v1/minutes/{minute_token}/summary`)。 + +## 典型触发表达 + +- "把这条妙记的总结改成……" +- "更新 / 替换妙记的 AI 总结" +- "修正总结内容后写回妙记" + +## 命令 + +```bash +# 直接传入总结内容(Markdown 子集) +lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary "**会议结论**\n- 方案 A 通过\n- 下周跟进排期" + +# 从文件读取总结内容 +lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @summary.md + +# 从 stdin 读取 +echo "**结论**" | lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @- + +# 预览 API 调用 +lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @summary.md --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记 Token | +| `--summary ` | 是 | 替换后的总结内容,支持 `@file` / `@-`(stdin) | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 核心约束 + +### 1. 先读后写 + +替换前建议先用 `lark-cli vc +notes --minute-tokens ` 读取当前总结,确认 `minute_token` 与待替换内容无误。 + +### 2. Markdown 展示说明 + +接口接受任意总结文本,**不会因 Markdown 格式校验失败而拒绝请求**。妙记客户端通常只能良好渲染以下 Markdown 子集;不支持的语法(如链接、代码块、四级标题等)会**按原始文本展示**(保留 Markdown 标记字符,不会渲染成对应样式)。Agent 写入时应优先使用可展示语法,避免用户在妙记里看到字面量的 `[链接](url)`、`` `code` `` 等: + +| 支持 | 写法 | 示例 | +|------|------|------| +| 纯文本 | 普通段落 | `本次会议讨论了 Q2 预算` | +| 换行 | `\n` 或空行 | 分段落书写 | +| 一级标题 | `# ` + 标题文字 | `# 会议结论` | +| 二级标题 | `## ` + 标题文字 | `## 行动项` | +| 三级标题 | `### ` + 标题文字 | `### 跟进事项` | +| 加粗 | `**文字**` | `**重点结论**` | +| 无序列表 | `- ` 或 `* ` | `- 跟进预算审批` | +| 有序列表 | `1. ` | `1. 确认需求` | + +> 标题语法建议:`#` 后保留空格,并优先使用 1~3 级(`#` / `##` / `###`)。四级及以上(`####`)无法渲染,会以原始文本形式展示。 + +**不建议使用**(会按原始文本展示):链接、图片、代码块、表格、引用块、斜体、删除线、四级及以上标题等。 + +合法示例: + +```markdown +# 会议结论 + +## 核心讨论 + +**方案 A 通过**,下周启动排期。 + +### 待跟进 +- 预算审批 +- 排期确认 + +1. 张三负责预算 +2. 李四负责排期 +``` + +### 3. 所需权限 + +| 身份 | 所需权限 | +|------|---------| +| user | `minutes:minutes:update` | + +## 输出结果 + +```json +{ + "minute_token": "obcnxxxxxxxxxxxxxxxxxxxx", + "updated": true +} +``` + +| 字段 | 说明 | +|------|------| +| `minute_token` | 妙记 Token | +| `updated` | 是否已成功更新 | + +## 如何获取 minute_token + +| 来源 | 获取方式 | +|------|---------| +| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` | +| 妙记搜索 | `lark-cli minutes +search --query "关键词"` | +| 会议产物查询 | `lark-cli vc +notes --minute-tokens ` | + +## 常见错误与排查 + +| 错误现象 | 错误码 | 根本原因 | 解决方案 | +|---------|--------|---------|---------| +| 总结展示为原始 Markdown 文本 | — | 总结含链接、四级标题等妙记端无法渲染的语法 | 改用标题(#~###)、加粗、列表等可展示格式;接口不会因此报错 | +| 参数无效 | — | `minute_token` 缺失或格式错误 | 检查 token 是否完整 | +| 权限不足 | — | 缺少 `minutes:minutes:update` | 运行 `auth login --scope "minutes:minutes:update"` | + +## 参考 + +- [lark-minutes](../SKILL.md) — 妙记全部命令 +- [minutes +todo](lark-minutes-todo.md) — 替换待办项 +- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-minutes/references/lark-minutes-todo.md b/skills/lark-minutes/references/lark-minutes-todo.md new file mode 100644 index 000000000..7a50656b0 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-todo.md @@ -0,0 +1,138 @@ +# minutes +todo + +> **路由**:本命令操作**妙记内的 AI 待办**,不是飞书任务(Task)。用户说「在妙记里新建待办」时**必须**用本命令,**禁止**走 `lark-cli task` / `tasklists list` / `task +create`。详见 [lark-minutes/SKILL.md](../SKILL.md) 第 6 节。 + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +对妙记中的待办做新增 / 更新 / 删除(单条或批量)。写操作。 + +本 skill 对应 shortcut:`lark-cli minutes +todo`(调用 `POST /open-apis/minutes/v1/minutes/{minute_token}/todo`)。 + +## 典型触发表达 + +- "给这条妙记加一条/多条待办" +- "把某条待办改成……" +- "标记某条待办为已完成 / 取消完成" +- "删除某条待办" + +## 命令 + +**单条模式**:`--operation` + 对应字段。 +**批量模式**:`--todos` JSON 数组(与单条 flags 互斥),一次请求可混合 `add` / `update` / `delete`。 + +```bash +# 单条:新增 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add --todo "跟进预算审批" --is-done=false --as user + +# 批量:一次新增两条 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --as user --todos '[ + {"operation":"add","content":"晚上好1","is_done":true}, + {"operation":"add","content":"晚上好2","is_done":false} +]' + +# 批量:混合增删改 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --as user --todos '[ + {"operation":"add","content":"新待办","is_done":false}, + {"operation":"update","todo_id":"1234567890","content":"已更新","is_done":true}, + {"operation":"delete","todo_id":"9876543210"} +]' + +# 从文件读取 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --as user --todos @todos.json + +# 单条:更新 / 删除 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation update --todo-id 1234567890 --todo "整理会议纪要" --is-done --as user +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation delete --todo-id 1234567890 --as user + +# 预览 +lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add --todo "新待办" --is-done --dry-run --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--minute-token ` | 是 | 妙记 Token | +| `--operation ` | 单条模式 | `add` / `update` / `delete`;与 `--todos` 互斥 | +| `--todo ` | 单条 add/update | 待办纯文本 | +| `--is-done` | 单条 add/update | `--is-done` = true,`--is-done=false` = false | +| `--todo-id ` | 单条 update/delete | 已有待办 id | +| `--todos ` | 批量模式 | JSON 数组,支持 `@file` / `@-`;与单条 flags 互斥 | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 单条模式 + +| `--operation` | 必填参数 | 禁止参数 | +|---------------|----------|----------| +| `add` | `--todo` + `--is-done` | `--todo-id` | +| `update` | `--todo-id` + `--todo` + `--is-done` | — | +| `delete` | `--todo-id` | `--todo`、`--is-done` | + +## 批量模式:`--todos` + +每条元素字段与 API `todo_items[]` 一致: + +| JSON 字段 | add | update | delete | +|-----------|-----|--------|--------| +| `operation` | 必填 | 必填 | 必填 | +| `content` | 必填 | 必填 | 禁止 | +| `is_done` | 必填 | 必填 | 禁止 | +| `todo_id` | 禁止 | 必填 | 必填 | + +示例 `todos.json`: + +```json +[ + {"operation": "add", "content": "晚上好1", "is_done": true}, + {"operation": "add", "content": "晚上好2", "is_done": false} +] +``` + +数组顺序会原样写入请求体;端上展示顺序仍可能受完成状态分组影响。 + +## 核心约束 + +### 1. 先读后写,待办 id 如何获取 + +更新 / 删除前先用 `lark-cli vc +notes --minute-tokens ` 读取当前待办。返回的每条待办带 `todo_id` 字段。 + +> 待办 id 仅用于程序内部定位,不必展示给用户。 + +### 2. 待办内容为纯文本 + +`content` **不是 Markdown**,请直接传入待办描述文字。 + +### 3. 所需权限 + +| 身份 | 所需 scope | +|------|-----------| +| user | `minutes:minutes:update` | + +## 输出结果 + +```json +{ + "minute_token": "obcnxxxxxxxxxxxxxxxxxxxx", + "count": 2, + "updated": true +} +``` + +单条模式额外包含 `"operation": "add"`。 + +## 常见错误与排查 + +| 错误现象 | 解决方案 | +|---------|---------| +| 未指定操作 | 单条模式传 `--operation`,或批量传 `--todos` | +| `--todos` 与单条 flags 冲突 | 二选一 | +| `todos[i]` 校验失败 | 检查该条 `operation` 与字段组合 | +| `error.type` = `no_edit_permission` | **妙记资源无编辑权**:向妙记所有者申请该妙记的编辑/协作权限;**不要**走 `auth login --scope` | +| 缺少 OAuth scope(`permission_violations` 含 `minutes:minutes:update`) | `lark-cli auth login --scope "minutes:minutes:update"` | + +## 参考 + +- [lark-minutes](../SKILL.md) +- [minutes +summary](lark-minutes-summary.md) +- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) +- [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index 6d3d89511..bde2a9362 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -16,7 +16,11 @@ metadata: > **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了清单查询关键字,则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),并且使用搜索或原生列取清单都能达到目的时,应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。 > **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。 > **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。 -> **术语理解**:如果用户提到 “todo”(待办),应当思考其是否是指“task”(任务),并优先尝试使用本 Skill 提供的命令来处理。 +> **术语理解 — 待办 disambiguation(必读)**: +> - 用户提到「待办 / todo / 任务」时,**先判断归属**,不要默认走本 skill。 +> - **走 [lark-minutes](../lark-minutes/SKILL.md) 的 `minutes +todo`**(禁止本 skill):上下文含 **妙记 / 会议纪要 / minute_token / 妙记 URL**(`/minutes/`);或「在某某妙记里新建/修改待办」「妙记 AI 待办」「会议录制里的待办」。 +> - **走本 skill(lark-task)**:任务清单、分配给我、项目待办、截止日期/提醒、子任务、任务清单成员;或 applink 含 `client/todo/task?guid=`;或明确说「飞书任务」「任务中心」「我的任务清单」。 +> - **禁止**:用户要在妙记里加待办时,**不要**调用 `task tasklists list`、`task +create` 或任何 task 命令去「找清单再放任务」。 > **友好输出**:在输出任务(或清单)的执行结果给用户时,建议同时提取并输出命令返回结果中的 `url` 字段(任务链接),以便用户可以直接点击跳转查看详情。 > **创建/更新注意**: diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md index a819c87cd..ccf83c99b 100644 --- a/skills/lark-vc/references/lark-vc-notes.md +++ b/skills/lark-vc/references/lark-vc-notes.md @@ -95,7 +95,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run | 字段 | 说明 | |------|------| | `artifacts.summary` | AI 总结(JSON 内联) | -| `artifacts.todos` | 待办事项(JSON 内联) | +| `artifacts.todos` | 待办事项(JSON 内联,**只读**);每条含 `content`、`is_done` 及 `todo_id`。`todo_id` 仅供 [`minutes +todo`](../../lark-minutes/references/lark-minutes-todo.md) 更新/删除待办时使用,不必展示给用户。**新建**妙记内待办请用 `minutes +todo`,不要用 lark-task | | `artifacts.chapters` | 章节纪要(JSON 内联) | | `artifacts.keywords` | 妙记推荐关键词(JSON 内联) | | `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` | diff --git a/tests/cli_e2e/minutes/minutes_word_replace_test.go b/tests/cli_e2e/minutes/minutes_word_replace_test.go new file mode 100644 index 000000000..2ba739d50 --- /dev/null +++ b/tests/cli_e2e/minutes/minutes_word_replace_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMinutesWordReplace_DryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+word-replace", + "--minute-token", "obcnexampleminute", + "--replace-words", `[{"source_word":"foo","target_word":"bar"}]`, + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/word"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "foo"), "dry-run should contain source_word, got: %s", output) + assert.True(t, strings.Contains(output, "bar"), "dry-run should contain target_word, got: %s", output) +} From 077b5e718039fb3b0f14bfc24252a11f7f56b0c8 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Wed, 10 Jun 2026 15:47:33 +0800 Subject: [PATCH 08/48] feat: configure initial base table schema (#1377) * feat: configure initial base table schema * fix: add base create table scopes --- shortcuts/base/base_create.go | 27 +- shortcuts/base/base_dryrun_ops_test.go | 53 ++++ shortcuts/base/base_execute_test.go | 249 ++++++++++++++++-- shortcuts/base/base_ops.go | 164 +++++++++++- shortcuts/base/base_shortcuts_test.go | 52 ++++ shortcuts/base/table_create.go | 2 +- shortcuts/base/table_ops.go | 79 +++--- skills/lark-base/SKILL.md | 4 +- tests/cli_e2e/base/base_create_dryrun_test.go | 112 ++++++++ 9 files changed, 681 insertions(+), 61 deletions(-) create mode 100644 tests/cli_e2e/base/base_create_dryrun_test.go diff --git a/shortcuts/base/base_create.go b/shortcuts/base/base_create.go index 47d5e27ed..d1108d632 100644 --- a/shortcuts/base/base_create.go +++ b/shortcuts/base/base_create.go @@ -14,18 +14,39 @@ var BaseBaseCreate = common.Shortcut{ Command: "+base-create", Description: "Create a new base resource", Risk: "write", - UserScopes: []string{"base:app:create"}, - BotScopes: []string{"base:app:create", "docs:permission.member:create"}, - AuthTypes: authTypes(), + UserScopes: []string{ + "base:app:create", + "base:table:read", + "base:table:create", + "base:table:update", + "base:table:delete", + }, + BotScopes: []string{ + "base:app:create", + "base:table:read", + "base:table:create", + "base:table:update", + "base:table:delete", + "docs:permission.member:create", + }, + AuthTypes: authTypes(), Flags: []common.Flag{ {Name: "name", Desc: "base name", Required: true}, {Name: "folder-token", Desc: "folder token for destination"}, {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, + {Name: "fields", Desc: `field JSON array for the first table schema; use with --table-name, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`}, + {Name: "table-name", Desc: "first table name for the custom first table schema; use with --fields"}, }, Tips: []string{ `Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`, + `Strongly recommended initial table schema: lark-cli base +base-create --name "Project Tracker" --table-name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]'`, + "Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.", + "If --table-name and --fields are both omitted, Base creates one initial table with the platform default schema.", "If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.", }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBaseCreate(runtime) + }, DryRun: dryRunBaseCreate, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeBaseCreate(runtime) diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 32993008d..8f34fb119 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -28,6 +28,14 @@ func TestDryRunTableOps(t *testing.T) { rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil) assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1") assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables") + + tableCreateWithFieldsRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "name": "Orders", "fields": `[{"name":"Title","type":"text"}]`}, + nil, + nil, + ) + assertDryRunContains(t, dryRunTableCreate(ctx, tableCreateWithFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables", `"fields":[{"name":"Title","type":"text"}]`) + assertDryRunContains(t, dryRunTableUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1") assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1") } @@ -264,6 +272,51 @@ func TestDryRunBaseOps(t *testing.T) { nil, ) assertDryRunContains(t, dryRunBaseCreate(ctx, createRT), "POST /open-apis/base/v3/bases") + + createWithFieldsRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "table-name": "Tasks", "fields": `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`}, + nil, + nil, + ) + assertDryRunContains( + t, + dryRunBaseCreate(ctx, createWithFieldsRT), + "POST /open-apis/base/v3/bases", + "GET /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables?limit=100&offset=0", + "POST /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", + `"name":"Tasks"`, + `"fields":[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`, + "DELETE /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", + ) + + createWithFieldsDefaultNameRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "fields": `[{"name":"Title","type":"text"}]`}, + nil, + nil, + ) + assertDryRunContains( + t, + dryRunBaseCreate(ctx, createWithFieldsDefaultNameRT), + "POST /open-apis/base/v3/bases", + "POST /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", + `"name":"Table 1"`, + `"fields":[{"name":"Title","type":"text"}]`, + "DELETE /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", + ) + + createWithTableNameRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "table-name": "Tasks"}, + nil, + nil, + ) + assertDryRunContains( + t, + dryRunBaseCreate(ctx, createWithTableNameRT), + "POST /open-apis/base/v3/bases", + "GET /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables?limit=100&offset=0", + "PATCH /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", + `"name":"Tasks"`, + ) } func TestDryRunDashboardOps(t *testing.T) { diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 22626be2a..79bd12ac4 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -132,6 +132,221 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) { } } +func TestBaseWorkspaceExecuteCreateWithFields(t *testing.T) { + oldDelay := baseCreateDefaultTableDeleteDelay + baseCreateDefaultTableDeleteDelay = 0 + t.Cleanup(func() { baseCreateDefaultTableDeleteDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_default", "name": "Table 1"}, + }}, + }, + }) + createTableStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_custom", "name": "Tasks", "fields": []interface{}{ + map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"}, + map[string]interface{}{"id": "fld_status", "name": "Status", "type": "text"}, + }}, + }, + } + reg.Register(createTableStub) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := runShortcut( + t, + BaseBaseCreate, + []string{"+base-create", "--name", "Demo Base", "--table-name", "Tasks", "--fields", `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true || data["default_table_deleted"] != true || data["deleted_default_table_id"] != "tbl_default" { + t.Fatalf("unexpected create output: %#v", data) + } + table, _ := data["table"].(map[string]interface{}) + if got := common.GetString(table, "id"); got != "tbl_custom" { + t.Fatalf("table.id = %q, want tbl_custom", got) + } + fields, _ := data["fields"].([]interface{}) + if len(fields) != 2 { + t.Fatalf("fields len = %d, want 2; output=%#v", len(fields), data["fields"]) + } + if strings.Contains(stderr.String(), baseCreateHint) { + t.Fatalf("stderr should not contain default-table cleanup hint when --fields handled cleanup: %q", stderr.String()) + } + + if body := decodeCapturedJSONBody(t, createTableStub); body["name"] != "Tasks" { + t.Fatalf("create table body = %#v", body) + } + body := decodeCapturedJSONBody(t, createTableStub) + fieldsBody, _ := body["fields"].([]interface{}) + if len(fieldsBody) != 2 { + t.Fatalf("create table fields body = %#v", body["fields"]) + } +} + +func TestBaseWorkspaceExecuteCreateWithFieldsDefaultTableName(t *testing.T) { + oldDelay := baseCreateDefaultTableDeleteDelay + baseCreateDefaultTableDeleteDelay = 0 + t.Cleanup(func() { baseCreateDefaultTableDeleteDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_default", "name": "Table 1"}, + }}, + }, + }) + createTableStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_custom", "name": "Table 1", "fields": []interface{}{ + map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"}, + }}, + }, + } + reg.Register(createTableStub) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := runShortcut( + t, + BaseBaseCreate, + []string{"+base-create", "--name", "Demo Base", "--fields", `[{"name":"Title","type":"text"}]`}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + body := decodeCapturedJSONBody(t, createTableStub) + if body["name"] != "Table 1" { + t.Fatalf("create table body = %#v, want name Table 1", body) + } +} + +func TestBaseWorkspaceExecuteCreateWithTableNameOnly(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_default", "name": "Table 1"}, + }}, + }, + }) + renameStub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_default", "name": "Tasks"}, + }, + } + reg.Register(renameStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := runShortcut( + t, + BaseBaseCreate, + []string{"+base-create", "--name", "Demo Base", "--table-name", "Tasks"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true || data["default_table_renamed"] != true || data["renamed_default_table_id"] != "tbl_default" { + t.Fatalf("unexpected create output: %#v", data) + } + if data["default_table_deleted"] == true { + t.Fatalf("table-name-only should not delete the default table: %#v", data) + } + table, _ := data["table"].(map[string]interface{}) + if got := common.GetString(table, "name"); got != "Tasks" { + t.Fatalf("table.name = %q, want Tasks", got) + } + if strings.Contains(stderr.String(), baseCreateHint) { + t.Fatalf("stderr should not contain default schema hint when --table-name handled rename: %q", stderr.String()) + } + body := decodeCapturedJSONBody(t, renameStub) + if body["name"] != "Tasks" { + t.Fatalf("rename table body = %#v", body) + } +} + func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) { t.Run("get", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) @@ -692,30 +907,21 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) { func TestBaseTableExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ + createTableStub := &httpmock.Stub{ Method: "POST", URL: "/open-apis/base/v3/bases/app_x/tables", Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"id": "tbl_new", "name": "Orders"}, + "data": map[string]interface{}{ + "id": "tbl_new", + "name": "Orders", + "fields": []interface{}{ + map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"}, + }, + }, }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_primary", "name": "Primary"}}}, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields/fld_primary", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"}, - }, - }) + } + reg.Register(createTableStub) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/views", @@ -731,6 +937,11 @@ func TestBaseTableExecuteCreate(t *testing.T) { if got := stdout.String(); !strings.Contains(got, `"table"`) || !strings.Contains(got, `"vew_main"`) { t.Fatalf("stdout=%s", got) } + body := decodeCapturedJSONBody(t, createTableStub) + fieldsBody, _ := body["fields"].([]interface{}) + if body["name"] != "Orders" || len(fieldsBody) != 1 { + t.Fatalf("create table body = %#v", body) + } } func TestBaseTableExecuteUpdate(t *testing.T) { diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go index dc0bba5a7..68bbf795b 100644 --- a/shortcuts/base/base_ops.go +++ b/shortcuts/base/base_ops.go @@ -7,11 +7,15 @@ import ( "context" "fmt" "strings" + "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) -const baseCreateHint = "Tip: New bases include a default empty table with 5-10 blank records. After finishing table/field setup on this base, ask whether to delete that default table. If yes, run +table-list first, then delete the default table." +const baseCreateHint = "Tip: Base created the platform default first-table schema. To configure the initial table schema during +base-create, pass both --table-name and --fields." + +var baseCreateDefaultTableDeleteDelay = time.Second func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -31,15 +35,43 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D } func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - d := common.NewDryRunAPI(). - POST("/open-apis/base/v3/bases"). - Body(buildBaseCreateBody(runtime)) + d := common.NewDryRunAPI() if runtime.IsBot() { d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.") } + d. + POST("/open-apis/base/v3/bases"). + Body(buildBaseCreateBody(runtime)) + hasFields := strings.TrimSpace(runtime.Str("fields")) != "" + hasTableName := strings.TrimSpace(runtime.Str("table-name")) != "" + if hasFields { + d.GET("/open-apis/base/v3/bases/:created_base_token/tables"). + Params(map[string]interface{}{"offset": 0, "limit": 100}). + Desc("If the create response does not include the default table ID, the CLI lists tables to find it."). + Set("created_base_token", "") + d.POST("/open-apis/base/v3/bases/:created_base_token/tables"). + Body(dryRunTableCreateBody(runtime, baseCreateFirstTableName(runtime))). + Desc("Create a replacement first table with --fields in the create-table body.") + d.DELETE("/open-apis/base/v3/bases/:created_base_token/tables/:default_table_id"). + Desc("After a 1s wait, delete the default table created with the Base."). + Set("default_table_id", "") + } else if hasTableName { + d.GET("/open-apis/base/v3/bases/:created_base_token/tables"). + Params(map[string]interface{}{"offset": 0, "limit": 100}). + Desc("If the create response does not include the default table ID, the CLI lists tables to find it."). + Set("created_base_token", "") + d.PATCH("/open-apis/base/v3/bases/:created_base_token/tables/:default_table_id"). + Body(map[string]interface{}{"name": baseCreateFirstTableName(runtime)}). + Desc("Rename the default first table when only --table-name is provided."). + Set("default_table_id", "") + } return d } +func validateBaseCreate(runtime *common.RuntimeContext) error { + return nil +} + func executeBaseGet(runtime *common.RuntimeContext) error { data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil) if err != nil { @@ -66,9 +98,28 @@ func executeBaseCreate(runtime *common.RuntimeContext) error { return err } out := map[string]interface{}{"base": data, "created": true} + if strings.TrimSpace(runtime.Str("fields")) != "" { + customTable, createdFields, defaultTableID, err := replaceBaseDefaultTable(runtime, data) + if err != nil { + return err + } + out["table"] = customTable + out["fields"] = createdFields + out["default_table_deleted"] = true + out["deleted_default_table_id"] = defaultTableID + } else if strings.TrimSpace(runtime.Str("table-name")) != "" { + renamedTable, defaultTableID, err := renameBaseDefaultTable(runtime, data) + if err != nil { + return err + } + out["table"] = renamedTable + out["default_table_renamed"] = true + out["renamed_default_table_id"] = defaultTableID + } else { + fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint) + } augmentBasePermissionGrant(runtime, out, data) runtime.Out(out, nil) - fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint) return nil } @@ -114,3 +165,106 @@ func extractBasePermissionToken(base map[string]interface{}) string { } return "" } + +func replaceBaseDefaultTable(runtime *common.RuntimeContext, base map[string]interface{}) (map[string]interface{}, []interface{}, string, error) { + baseToken := extractBasePermissionToken(base) + if baseToken == "" { + return nil, nil, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create --fields could not find base_token/app_token in create response") + } + defaultTableID, err := findCreatedBaseDefaultTableID(runtime, baseToken, base, "--fields") + if err != nil { + return nil, nil, "", err + } + tableBody, err := buildTableCreateBody(runtime, newParseCtx(runtime), baseCreateFirstTableName(runtime)) + if err != nil { + return nil, nil, "", err + } + customTable, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, tableBody) + if err != nil { + return nil, nil, "", err + } + customTableID := tableID(customTable) + if customTableID == "" { + return nil, nil, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create --fields could not find table_id/id in replacement table create response") + } + createdFields := []interface{}{} + if fields, ok := customTable["fields"].([]interface{}); ok { + createdFields = fields + } + time.Sleep(baseCreateDefaultTableDeleteDelay) + if _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", defaultTableID), nil, nil); err != nil { + return nil, nil, "", err + } + return customTable, createdFields, defaultTableID, nil +} + +func renameBaseDefaultTable(runtime *common.RuntimeContext, base map[string]interface{}) (map[string]interface{}, string, error) { + baseToken := extractBasePermissionToken(base) + if baseToken == "" { + return nil, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create --table-name could not find base_token/app_token in create response") + } + defaultTableID, err := findCreatedBaseDefaultTableID(runtime, baseToken, base, "--table-name") + if err != nil { + return nil, "", err + } + renamedTable, err := baseV3Call( + runtime, + "PATCH", + baseV3Path("bases", baseToken, "tables", defaultTableID), + nil, + map[string]interface{}{"name": baseCreateFirstTableName(runtime)}, + ) + if err != nil { + return nil, "", err + } + return renamedTable, defaultTableID, nil +} + +func baseCreateFirstTableName(runtime *common.RuntimeContext) string { + if name := strings.TrimSpace(runtime.Str("table-name")); name != "" { + return name + } + return "Table 1" +} + +func findCreatedBaseDefaultTableID(runtime *common.RuntimeContext, baseToken string, base map[string]interface{}, flag string) (string, error) { + if tableIDValue := tableIDFromCreateBaseResponse(base); tableIDValue != "" { + return tableIDValue, nil + } + tables, _, err := listAllTables(runtime, baseToken, 0, 100) + if err != nil { + return "", err + } + if len(tables) == 0 { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create "+flag+" could not find the default table created with the Base") + } + if id := tableID(tables[0]); id != "" { + return id, nil + } + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create "+flag+" could not find table_id/id in default table list response") +} + +func tableIDFromCreateBaseResponse(base map[string]interface{}) string { + for _, key := range []string{"table_id", "default_table_id"} { + if id := strings.TrimSpace(common.GetString(base, key)); id != "" { + return id + } + } + for _, key := range []string{"table", "default_table"} { + if table, ok := base[key].(map[string]interface{}); ok { + if id := tableID(table); id != "" { + return id + } + } + } + for _, key := range []string{"tables", "default_tables"} { + if tables, ok := base[key].([]interface{}); ok && len(tables) > 0 { + if table, ok := tables[0].(map[string]interface{}); ok { + if id := tableID(table); id != "" { + return id + } + } + } + } + return "" +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index e304d4f18..05f51da3b 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -576,6 +576,14 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) { shortcut common.Shortcut wantHelp []string }{ + { + name: "base create fields", + shortcut: BaseBaseCreate, + wantHelp: []string{ + `field JSON array for the first table schema; use with --table-name`, + `first table name for the custom first table schema; use with --fields`, + }, + }, { name: "table create fields", shortcut: BaseTableCreate, @@ -986,6 +994,50 @@ func TestBaseTableValidate(t *testing.T) { } } +func TestBaseCreateValidate(t *testing.T) { + ctx := context.Background() + if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks"}, nil, nil)); err != nil { + t.Fatalf("table-name-only should be valid, err=%v", err) + } + if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks", "fields": `[{"name":"Title","type":"text"}]`}, nil, nil)); err != nil { + t.Fatalf("create validate err=%v", err) + } +} + +func TestBaseCreateTipsGuideFieldSchema(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseBaseCreate.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range []string{ + "Before using --fields, read lark-base-field-json.md", + "do not invent field properties", + } { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } +} + +func TestBaseCreateScopesCoverFollowUpTableOperations(t *testing.T) { + requiredUserScopes := []string{ + "base:app:create", + "base:table:read", + "base:table:create", + "base:table:update", + "base:table:delete", + } + if !reflect.DeepEqual(BaseBaseCreate.UserScopes, requiredUserScopes) { + t.Fatalf("UserScopes=%v want=%v", BaseBaseCreate.UserScopes, requiredUserScopes) + } + + requiredBotScopes := append(append([]string{}, requiredUserScopes...), "docs:permission.member:create") + if !reflect.DeepEqual(BaseBaseCreate.BotScopes, requiredBotScopes) { + t.Fatalf("BotScopes=%v want=%v", BaseBaseCreate.BotScopes, requiredBotScopes) + } +} + func TestBaseRecordValidate(t *testing.T) { ctx := context.Background() if BaseRecordList.Validate == nil { diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go index b49c0a4c0..2175080e1 100644 --- a/shortcuts/base/table_create.go +++ b/shortcuts/base/table_create.go @@ -24,7 +24,7 @@ var BaseTableCreate = common.Shortcut{ }, Tips: []string{ "Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.", - "The first --fields item replaces the default field.", + "The first --fields item becomes the primary field.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateTableCreate(runtime) diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index 909709122..5d986eab6 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "strings" "github.com/larksuite/cli/shortcuts/common" ) @@ -29,10 +30,12 @@ func dryRunTableGet(_ context.Context, runtime *common.RuntimeContext) *common.D } func dryRunTableCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - return common.NewDryRunAPI(). + body := dryRunTableCreateBody(runtime, runtime.Str("name")) + d := common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables"). - Body(map[string]interface{}{"name": runtime.Str("name")}). + Body(body). Set("base_token", runtime.Str("base-token")) + return d } func dryRunTableUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -96,43 +99,21 @@ func executeTableGet(runtime *common.RuntimeContext) error { func executeTableCreate(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") - created, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, map[string]interface{}{"name": runtime.Str("name")}) + pc := newParseCtx(runtime) + body, err := buildTableCreateBody(runtime, pc, runtime.Str("name")) + if err != nil { + return err + } + created, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, body) if err != nil { return err } result := map[string]interface{}{"table": created} tableIDValue := tableID(created) - pc := newParseCtx(runtime) if tableIDValue != "" && runtime.Str("fields") != "" { - fieldItems, err := parseJSONArray(pc, runtime.Str("fields"), "fields") - if err != nil { - return err + if fields, ok := created["fields"]; ok { + result["fields"] = fields } - defaultFields, err := listEveryField(runtime, baseToken, tableIDValue) - if err != nil { - return err - } - createdFields := []interface{}{} - for idx, item := range fieldItems { - body, ok := item.(map[string]interface{}) - if !ok { - return baseValidationErrorf("--fields item %d must be an object", idx+1) - } - if idx == 0 && len(defaultFields) > 0 { - fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body) - if err != nil { - return err - } - createdFields = append(createdFields, fieldData) - continue - } - fieldData, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields"), nil, body) - if err != nil { - return err - } - createdFields = append(createdFields, fieldData) - } - result["fields"] = createdFields } if tableIDValue != "" && runtime.Str("view") != "" { viewItems, err := parseObjectList(pc, runtime.Str("view"), "view") @@ -153,6 +134,40 @@ func executeTableCreate(runtime *common.RuntimeContext) error { return nil } +func buildTableCreateBody(runtime *common.RuntimeContext, pc *parseCtx, tableName string) (map[string]interface{}, error) { + body := map[string]interface{}{"name": tableName} + if strings.TrimSpace(runtime.Str("fields")) == "" { + return body, nil + } + fieldItems, err := parseJSONArray(pc, runtime.Str("fields"), "fields") + if err != nil { + return nil, err + } + for idx, item := range fieldItems { + if _, ok := item.(map[string]interface{}); !ok { + return nil, baseValidationErrorf("--fields item %d must be an object", idx+1) + } + } + if len(fieldItems) > 0 { + body["fields"] = fieldItems + } + return body, nil +} + +func dryRunTableCreateBody(runtime *common.RuntimeContext, tableName string) map[string]interface{} { + body := map[string]interface{}{"name": tableName} + if strings.TrimSpace(runtime.Str("fields")) == "" { + return body + } + fieldItems, err := parseJSONArray(newParseCtx(runtime), runtime.Str("fields"), "fields") + if err != nil { + body["fields"] = "" + return body + } + body["fields"] = fieldItems + return body +} + func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { const pageLimit = 100 offset := 0 diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 1bfab7198..f211dcb54 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -40,7 +40,7 @@ metadata: | 用户目标 | 优先命令 | 何时读 reference | |---|---|---| | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token | -| 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | +| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema;写入后报告新 Base 标识和 `permission_grant` | | 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | @@ -66,6 +66,8 @@ metadata: - Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。 - `+base-block-list` 是查看一个 Base 内资源目录的新入口:它列出这个 Base 直接管理的 `folder/table/docx/dashboard/workflow`,适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。 - `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除;具体资源内容仍走 table/dashboard/workflow 命令。 +- 新建 Base 时,强烈推荐一次性执行 `lark-cli base +base-create --name "" --table-name "" --fields ''`,同时配置新 Base 里唯一一个初始数据表的 name 和 schema;使用 `--fields` 前先读 [lark-base-field-json.md](references/lark-base-field-json.md) 或复用 `+field-create` 的字段 JSON 形状,不要猜字段属性。 +- `+base-create` 不传 `--table-name` 和 `--fields` 时,会创建一个默认 schema 的初始数据表。 - 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 - 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 - 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 diff --git a/tests/cli_e2e/base/base_create_dryrun_test.go b/tests/cli_e2e/base/base_create_dryrun_test.go new file mode 100644 index 000000000..6469b5bde --- /dev/null +++ b/tests/cli_e2e/base/base_create_dryrun_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseCreateDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-create", + "--name", "Project Tracker", + "--table-name", "Tasks", + "--time-zone", "Asia/Shanghai", + "--fields", `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`, + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "Project Tracker", gjson.Get(out, "api.0.body.name").String(), out) + require.Equal(t, "Asia/Shanghai", gjson.Get(out, "api.0.body.time_zone").String(), out) + + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.1.url").String(), out) + require.Equal(t, "GET", gjson.Get(out, "api.1.method").String(), out) + require.Equal(t, int64(0), gjson.Get(out, "api.1.params.offset").Int(), out) + require.Equal(t, int64(100), gjson.Get(out, "api.1.params.limit").Int(), out) + + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.2.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), out) + require.Equal(t, "Tasks", gjson.Get(out, "api.2.body.name").String(), out) + require.Equal(t, "Title", gjson.Get(out, "api.2.body.fields.0.name").String(), out) + require.Equal(t, "Status", gjson.Get(out, "api.2.body.fields.1.name").String(), out) + + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", gjson.Get(out, "api.3.url").String(), out) + require.Equal(t, "DELETE", gjson.Get(out, "api.3.method").String(), out) +} + +func TestBaseCreateDryRunTableNameOnlyRenamesDefaultTable(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-create", + "--name", "Project Tracker", + "--table-name", "Tasks", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "Project Tracker", gjson.Get(out, "api.0.body.name").String(), out) + + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.1.url").String(), out) + require.Equal(t, "GET", gjson.Get(out, "api.1.method").String(), out) + + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", gjson.Get(out, "api.2.url").String(), out) + require.Equal(t, "PATCH", gjson.Get(out, "api.2.method").String(), out) + require.Equal(t, "Tasks", gjson.Get(out, "api.2.body.name").String(), out) + require.False(t, gjson.Get(out, "api.3").Exists(), out) +} + +func TestBaseCreateDryRunFieldsOnlyUsesDefaultTableName(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-create", + "--name", "Project Tracker", + "--fields", `[{"name":"Title","type":"text"}]`, + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.2.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), out) + require.Equal(t, "Table 1", gjson.Get(out, "api.2.body.name").String(), out) + require.Equal(t, "Title", gjson.Get(out, "api.2.body.fields.0.name").String(), out) +} From e794fd59251791259c4198d1e8011cce88420bd1 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Wed, 10 Jun 2026 16:29:21 +0800 Subject: [PATCH 09/48] =?UTF-8?q?docs(skills):=20remove=20unsupported=20?= =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20from=20callout=20emoji=20list=20(#1374)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/lark-doc/references/lark-doc-xml.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md index 6a25447b0..7e3380bae 100644 --- a/skills/lark-doc/references/lark-doc-xml.md +++ b/skills/lark-doc/references/lark-doc-xml.md @@ -96,7 +96,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr | 高亮框填充 `` | `gray` + `light-{色}` + `medium-{色}` | | 单元格背景 `
` | 同文字背景 | | 按钮背景 `