Compare commits

...

1 Commits

Author SHA1 Message Date
zhaoyukun.yk
78e9d4c597 feat(doc,markdown,sheets,slides,wiki): emit typed error envelopes across ccm domains
Classify doc, markdown, sheets, slides, and wiki shortcut failures with typed errors so CLI users and automation receive more specific, actionable diagnostics.

Keep the migration scoped to the CCM shortcut domains, their error-contract guards, and the now-unreachable legacy save-error helper cleanup required after those domains stop calling it. Align the common-helper AST guard with latest main's migrated paths without reintroducing shortcuts removed upstream.
2026-06-09 12:13:58 +08:00
80 changed files with 1422 additions and 1051 deletions

View File

@@ -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/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -18,13 +18,19 @@ var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -19,14 +19,19 @@ var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/",
"shortcuts/im/",
"shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
"shortcuts/wiki/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -944,11 +944,19 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"HandleApiResult",
}
paths := []string{
"shortcuts/calendar/calendar_create.go",
"shortcuts/contact/contact_search_user.go",
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
"shortcuts/wiki/wiki_node_get.go",
}
for _, path := range paths {
for _, helper := range helpers {
@@ -997,6 +1005,34 @@ func boom() {
}
}
func TestCheckNoLegacyCommonHelperCall_CoversCCMPathsWithAliasAndFunctionValue(t *testing.T) {
paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/wiki/wiki_node_get.go",
}
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
for _, path := range paths {
t.Run(path, func(t *testing.T) {
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on %s, got %d: %+v", path, len(v), v)
}
})
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact

View File

@@ -676,30 +676,10 @@ func WrapInputStatErrorTyped(err error, readMsg ...string) error {
WithCause(err)
}
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).
//
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return output.ErrValidation("unsafe output path: %s", err)
case errors.As(err, &me):
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
default:
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
}
}
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
// "internal" wire type: call sites migrating from a custom category
// (e.g. "io", "api_error") change their envelope's type field.
// Non-path failures always emit the canonical "internal" wire type: call sites
// migrating from a custom legacy category (e.g. "io", "api_error") change
// their envelope's type field.
func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil

View File

@@ -11,6 +11,8 @@ import (
"regexp"
"runtime"
"strings"
"github.com/larksuite/cli/errs"
)
// readClipboardImageBytes reads the current clipboard image and returns the
@@ -35,13 +37,13 @@ func readClipboardImageBytes() ([]byte, error) {
case "linux":
data, err = readClipboardLinux()
default:
return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS)
}
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("clipboard contains no image data")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
}
return data, nil
}
@@ -91,9 +93,9 @@ func readClipboardDarwin() ([]byte, error) {
}
if stderrText != "" {
return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText)
}
return nil, fmt.Errorf("clipboard contains no image data")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data")
}
// runOsascript invokes osascript with a single AppleScript expression and
@@ -188,14 +190,14 @@ func decodeOsascriptData(s string) ([]byte, error) {
// decodeHex decodes an uppercase hex string (as produced by osascript) to bytes.
func decodeHex(h string) ([]byte, error) {
if len(h)%2 != 0 {
return nil, fmt.Errorf("odd hex length")
return nil, fmt.Errorf("odd hex length") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
}
b := make([]byte, len(h)/2)
for i := 0; i < len(h); i += 2 {
hi := hexVal(h[i])
lo := hexVal(h[i+1])
if hi < 0 || lo < 0 {
return nil, fmt.Errorf("invalid hex char at %d", i)
return nil, fmt.Errorf("invalid hex char at %d", i) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error
}
b[i/2] = byte(hi<<4 | lo)
}
@@ -237,12 +239,12 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("clipboard read failed (%s)", msg)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg)
}
b64 := strings.TrimSpace(string(out))
data, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return nil, fmt.Errorf("clipboard image decode failed: %w", decErr)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr)
}
return data, nil
}
@@ -325,15 +327,15 @@ func readClipboardLinux() ([]byte, error) {
foundTool = true
out, err := exec.Command(t.name, t.args...).Output()
if err != nil {
lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err)
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err)
continue
}
if len(out) == 0 {
lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name)
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name)
continue
}
if t.validatePNG && !hasPNGMagic(out) {
lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name)
lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name)
continue
}
return out, nil
@@ -342,8 +344,8 @@ func readClipboardLinux() ([]byte, error) {
if foundTool && lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf(
"clipboard image read failed: no supported tool found. " +
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager " +
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"clipboard image read failed: no supported tool found. "+
"Install one of xclip, wl-clipboard, or xsel via your distro's package manager "+
"(apt, dnf, pacman, apk, brew, etc.).")
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import "github.com/larksuite/cli/errs"
// wrapDocNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving its subtype / code / log_id from the runtime boundary),
// and only wraps a raw, unclassified error as a transport-level network error.
func wrapDocNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

View File

@@ -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)

View File

@@ -15,8 +15,8 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -67,10 +67,10 @@ var DocMediaInsert = common.Shortcut{
filePath := runtime.Str("file")
fromClipboard := runtime.Bool("from-clipboard")
if filePath == "" && !fromClipboard {
return common.FlagErrorf("one of --file or --from-clipboard is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required")
}
if filePath != "" && fromClipboard {
return common.FlagErrorf("--file and --from-clipboard are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive")
}
docRef, err := parseDocumentRef(runtime.Str("doc"))
@@ -78,7 +78,7 @@ var DocMediaInsert = common.Shortcut{
return err
}
if docRef.Kind == "doc" {
return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
}
rawSelection := runtime.Str("selection-with-ellipsis")
trimmedSelection := strings.TrimSpace(rawSelection)
@@ -87,36 +87,36 @@ var DocMediaInsert = common.Shortcut{
// trim-to-empty would make +media-insert fall back to append-mode and
// write at the wrong location.
if rawSelection != "" && trimmedSelection == "" {
return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis must not be blank or whitespace-only").WithParam("--selection-with-ellipsis")
}
if runtime.Bool("before") && trimmedSelection == "" {
return output.ErrValidation("--before requires --selection-with-ellipsis")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--before requires --selection-with-ellipsis").WithParam("--before")
}
if view := runtime.Str("file-view"); view != "" {
if _, ok := fileViewMap[view]; !ok {
return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-view value %q, expected one of: card | preview | inline", view).WithParam("--file-view")
}
if runtime.Str("type") != "file" {
return output.ErrValidation("--file-view only applies when --type=file")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-view only applies when --type=file").WithParam("--file-view")
}
}
widthChanged := runtime.Changed("width")
heightChanged := runtime.Changed("height")
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
return output.ErrValidation("--width/--height only apply when --type=image")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image")
}
if widthChanged && runtime.Int("width") <= 0 {
return output.ErrValidation("--width must be a positive integer")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width")
}
if heightChanged && runtime.Int("height") <= 0 {
return output.ErrValidation("--height must be a positive integer")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be a positive integer").WithParam("--height")
}
const maxDimension = 10000
if widthChanged && runtime.Int("width") > maxDimension {
return output.ErrValidation("--width must not exceed %d pixels", maxDimension)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must not exceed %d pixels", maxDimension).WithParam("--width")
}
if heightChanged && runtime.Int("height") > maxDimension {
return output.ErrValidation("--height must not exceed %d pixels", maxDimension)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must not exceed %d pixels", maxDimension).WithParam("--height")
}
return nil
},
@@ -269,10 +269,10 @@ var DocMediaInsert = common.Shortcut{
} else {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return common.WrapInputStatErrorTyped(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
fileSize = stat.Size()
fileName = filepath.Base(filePath)
@@ -284,7 +284,7 @@ var DocMediaInsert = common.Shortcut{
}
// Step 1: Get document root block to find where to insert
rootData, err := runtime.CallAPI("GET",
rootData, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)),
nil, nil)
if err != nil {
@@ -318,7 +318,7 @@ var DocMediaInsert = common.Shortcut{
// Step 2: Create an empty block at the target position
fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex)
createData, err := runtime.CallAPI("POST",
createData, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
nil, buildCreateBlockData(mediaType, insertIndex, fileViewType))
if err != nil {
@@ -328,7 +328,7 @@ var DocMediaInsert = common.Shortcut{
blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType)
if blockId == "" {
return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create block: no block_id returned")
}
fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId)
@@ -340,7 +340,7 @@ var DocMediaInsert = common.Shortcut{
// later steps should try to remove it instead of leaving an empty artifact.
rollback := func() error {
fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId)
_, err := runtime.CallAPI("DELETE",
_, err := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)),
nil, buildDeleteBlockData(insertIndex))
return err
@@ -379,14 +379,14 @@ var DocMediaInsert = common.Shortcut{
} else {
f, openErr := runtime.FileIO().Open(filePath)
if openErr != nil {
return withRollbackWarning(output.ErrValidation(
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
}
nativeW, nativeH, dimErr = detectImageDimensions(f)
f.Close()
}
if dimErr != nil {
return withRollbackWarning(output.ErrValidation(
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
}
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
@@ -417,7 +417,7 @@ var DocMediaInsert = common.Shortcut{
// Step 4: Bind file token to block via batch_update
fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID)
if _, err := runtime.CallAPI("PATCH",
if _, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)),
nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil {
return withRollbackWarning(err)
@@ -512,10 +512,10 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
case "docx":
return docRef.Token, nil
case "doc":
return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docRef.Token},
@@ -529,16 +529,16 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "docx" {
return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
return objToken, nil
default:
return "", output.ErrValidation("docs +media-insert only supports docx documents")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
}
}
@@ -622,7 +622,7 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin
func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (parentBlockID string, insertIndex int, children []interface{}, err error) {
block, _ := rootData["block"].(map[string]interface{})
if len(block) == 0 {
return "", 0, nil, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block")
return "", 0, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to query document root block")
}
parentBlockID = fallbackBlockID
@@ -653,12 +653,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
matches := common.GetSlice(result, "matches")
if len(matches) == 0 {
return 0, output.ErrWithHint(
output.ExitValidation,
"no_match",
fmt.Sprintf("locate-doc did not find any block matching selection (%s)", redactSelection(selection)),
"check spelling or use 'start...end' syntax to narrow the selection",
)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"locate-doc did not find any block matching selection (%s)", redactSelection(selection)).
WithParam("--selection-with-ellipsis").
WithHint("check spelling or use 'start...end' syntax to narrow the selection")
}
if len(matches) > 1 {
// Silently picking the first match surprises users whose selection appears
@@ -682,7 +680,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
}
}
if anchorBlockID == "" {
return 0, output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
return 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
}
parentBlockID := common.GetString(matchMap, "parent_block_id")
@@ -740,7 +738,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
nextParent = "" // clear hint after first use
if parent == "" || parent == cur {
// Need to fetch this block to find its parent.
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s",
validate.EncodePathSegment(documentID), validate.EncodePathSegment(cur)),
nil, nil)
@@ -757,12 +755,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select
walkDepth++
}
return 0, output.ErrWithHint(
output.ExitValidation,
"block_not_reachable",
fmt.Sprintf("block matching selection (%s) is not reachable from document root", redactSelection(selection)),
"try a top-level heading or paragraph as the selection",
)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"block matching selection (%s) is not reachable from document root", redactSelection(selection)).
WithParam("--selection-with-ellipsis").
WithHint("try a top-level heading or paragraph as the selection")
}
func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) {

View File

@@ -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)

View File

@@ -9,8 +9,8 @@ import (
"io"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -84,10 +84,10 @@ var DocMediaUpload = common.Shortcut{
// Validate file
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return common.WrapInputStatErrorTyped(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
fileName := filepath.Base(filePath)

View File

@@ -7,6 +7,7 @@ import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,10 +26,10 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive")
}
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,7 +38,7 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return common.FlagErrorf("invalid --doc: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
if err := validateFetchDetail(runtime); err != nil {
return err
@@ -153,7 +154,7 @@ func validateFetchDetail(runtime *common.RuntimeContext) error {
return nil
}
if detail == "with-ids" || detail == "full" {
return common.FlagErrorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
}
return nil
}
@@ -166,13 +167,13 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
}
if v := runtime.Int("context-before"); v < 0 {
return common.FlagErrorf("--context-before must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-before must be >= 0, got %d", v).WithParam("--context-before")
}
if v := runtime.Int("context-after"); v < 0 {
return common.FlagErrorf("--context-after must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-after must be >= 0, got %d", v).WithParam("--context-after")
}
if v := runtime.Int("max-depth"); v < -1 {
return common.FlagErrorf("--max-depth must be >= -1, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-depth must be >= -1, got %d", v).WithParam("--max-depth")
}
switch mode {
@@ -181,20 +182,20 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
case "range":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
return common.FlagErrorf("range mode requires --start-block-id or --end-block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id")
}
return nil
case "keyword":
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return common.FlagErrorf("keyword mode requires --keyword")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keyword mode requires --keyword").WithParam("--keyword")
}
return nil
case "section":
if strings.TrimSpace(runtime.Str("start-block-id")) == "" {
return common.FlagErrorf("section mode requires --start-block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "section mode requires --start-block-id").WithParam("--start-block-id")
}
return nil
default:
return common.FlagErrorf("invalid --scope %q", mode)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
}
}

View File

@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -58,7 +59,7 @@ var DocsSearch = common.Shortcut{
return err
}
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData)
if err != nil {
return err
}
@@ -159,7 +160,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
var filter map[string]interface{}
if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
return nil, output.ErrValidation("--filter is not valid JSON")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter")
}
if err := convertTimeRangeInFilter(filter, "open_time"); err != nil {
return nil, err
@@ -172,7 +173,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma
hasSpaceIDs := hasNonEmptyFilterArray(filter, "space_ids")
if hasFolderTokens && hasSpaceIDs {
return nil, output.ErrValidation("--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined").WithParam("--filter")
}
docFilter := cloneFilterMap(filter)
@@ -225,14 +226,14 @@ func convertTimeRangeInFilter(filter map[string]interface{}, key string) error {
if start, ok := rangeMap["start"].(string); ok && start != "" {
startTime, err := toUnixSeconds(start)
if err != nil {
return output.ErrValidation("invalid %s.start %q: %s", key, start, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter")
}
result["start"] = startTime
}
if end, ok := rangeMap["end"].(string); ok && end != "" {
endTime, err := toUnixSeconds(end)
if err != nil {
return output.ErrValidation("invalid %s.end %q: %s", key, end, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter")
}
result["end"] = endTime
}
@@ -256,7 +257,7 @@ func toUnixSeconds(input string) (int64, error) {
if n, err := strconv.ParseInt(input, 10, 64); err == nil {
return n, nil
}
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds")
return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
func unixTimestampToISO8601(v interface{}) string {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -6,7 +6,6 @@ package markdown
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -19,7 +18,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -85,16 +83,18 @@ func (spec markdownUploadSpec) Target() markdownUploadTarget {
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
switch {
case spec.ContentSet && spec.FileSet:
return common.FlagErrorf("--content and --file are mutually exclusive")
return markdownValidationError("--content and --file are mutually exclusive").
WithParams(markdownInvalidParam("--content", "mutually exclusive"), markdownInvalidParam("--file", "mutually exclusive"))
case !spec.ContentSet && !spec.FileSet:
return common.FlagErrorf("specify exactly one of --content or --file")
return markdownValidationError("specify exactly one of --content or --file").
WithParams(markdownInvalidParam("--content", "required; specify exactly one"), markdownInvalidParam("--file", "required; specify exactly one"))
}
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
return markdownValidationParamError("--folder-token", "--folder-token cannot be empty; omit it to upload into Drive root folder")
}
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
return markdownValidationParamError("--wiki-token", "--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
}
targets := 0
if spec.FolderToken != "" {
@@ -104,22 +104,23 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
targets++
}
if targets > 1 {
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
return markdownValidationError("--folder-token and --wiki-token are mutually exclusive").
WithParams(markdownInvalidParam("--folder-token", "mutually exclusive"), markdownInvalidParam("--wiki-token", "mutually exclusive"))
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--folder-token", "%s", err).WithCause(err)
}
}
if spec.WikiToken != "" {
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--wiki-token", "%s", err).WithCause(err)
}
}
if requireName && spec.ContentSet {
if strings.TrimSpace(spec.FileName) == "" {
return common.FlagErrorf("--name is required when using --content")
return markdownValidationParamError("--name", "--name is required when using --content")
}
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
return err
@@ -128,10 +129,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
if spec.FileSet {
if strings.TrimSpace(spec.FilePath) == "" {
return common.FlagErrorf("--file cannot be empty")
return markdownValidationParamError("--file", "--file cannot be empty")
}
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
}
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
return err
@@ -154,10 +155,10 @@ func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string
func validateMarkdownFileName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return common.FlagErrorf("%s cannot be empty", flagName)
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
return common.FlagErrorf("%s must end with .md", flagName)
return markdownValidationParamError(flagName, "%s must end with .md", flagName)
}
return nil
}
@@ -201,22 +202,9 @@ func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, f
return resp, nil
}
func wrapMarkdownDownloadError(err error) error {
// Preserve any already-classified error: legacy *output.ExitError or any
// typed errs.* error. Only un-classified errors get wrapped as network.
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return output.ErrNetwork("download failed: %s", err)
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
return markdownValidationError("%s", markdownEmptyContentError)
}
return nil
}
@@ -227,12 +215,12 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
size = int64(len(spec.Content))
} else {
if strings.TrimSpace(spec.FilePath) == "" {
return 0, common.FlagErrorf("--file cannot be empty")
return 0, markdownValidationParamError("--file", "--file cannot be empty")
}
info, err := runtime.FileIO().Stat(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
return 0, common.WrapInputStatErrorTyped(err)
}
size = info.Size()
}
@@ -563,7 +551,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
if readErr != nil {
return output.ErrValidation("cannot read file: %s", readErr)
return markdownValidationError("cannot read file: %s", readErr).WithCause(readErr)
}
fd := larkcore.NewFormdata()

View File

@@ -5,7 +5,6 @@ package markdown
import (
"context"
"errors"
"fmt"
"io"
"regexp"
@@ -14,6 +13,7 @@ import (
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -65,7 +65,7 @@ type markdownDiffHunkRange struct {
func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
if spec.FromVersion != "" {
if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil {
@@ -79,29 +79,29 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
}
if spec.FilePath != "" {
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
return output.ErrValidation("unsafe file path: %s", err)
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
}
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
return err
}
}
if spec.ContextLines < 0 {
return output.ErrValidation("--context-lines must be >= 0")
return markdownValidationParamError("--context-lines", "--context-lines must be >= 0")
}
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
return output.ErrValidation("markdown +diff only supports --format json or pretty")
return markdownValidationParamError("--format", "markdown +diff only supports --format json or pretty")
}
if spec.FilePath == "" {
if spec.FromVersion == "" && spec.ToVersion == "" {
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
return markdownValidationError("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
}
if spec.FromVersion == "" && spec.ToVersion != "" {
return common.FlagErrorf("--to-version requires --from-version")
return markdownValidationParamError("--to-version", "--to-version requires --from-version")
}
return nil
}
if spec.ToVersion != "" {
return common.FlagErrorf("--to-version is not supported together with --file")
return markdownValidationParamError("--to-version", "--to-version is not supported together with --file")
}
return nil
}
@@ -109,10 +109,10 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
if !markdownDiffVersionRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric version string", flagName)
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
}
return nil
}
@@ -178,17 +178,16 @@ func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext
func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", common.WrapInputStatErrorTyped(err)
}
defer f.Close()
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if _, ok := errs.ProblemOf(err); ok {
return "", err
}
return "", output.ErrValidation("cannot read file: %s", err)
return "", markdownValidationError("cannot read file: %s", err).WithCause(err)
}
return string(payload), nil
}
@@ -199,7 +198,7 @@ func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
return nil, err
}
if len(payload) > markdownDiffMaxContentBytes {
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
return nil, markdownValidationError("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
}
return payload, nil
}

View File

@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -214,18 +215,18 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
}
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(99991663)
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
}
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
problem, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("wrapMarkdownDownloadError() = %T, want typed problem", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
if problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
t.Fatalf("wrapped error = %q", got.Error())

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"strings"
"github.com/larksuite/cli/errs"
)
func markdownValidationError(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func markdownValidationParamError(param, format string, args ...any) *errs.ValidationError {
return markdownValidationError(format, args...).WithParam(param)
}
func markdownInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: name, Reason: reason}
}
func markdownNetworkError(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
func wrapMarkdownDownloadError(err error) error {
if p, ok := errs.ProblemOf(err); ok {
if p.Category == errs.CategoryValidation {
return err
}
return markdownPrefixProblem(err, "download failed")
}
return markdownNetworkError(err, "download failed: %s", err)
}
func markdownPrefixProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(action) != "" {
p.Message = action + ": " + p.Message
}
return err
}
return errs.WrapInternal(err)
}

View File

@@ -14,7 +14,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,14 +34,14 @@ var MarkdownFetch = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath == "" {
return nil
}
if _, err := validate.SafeOutputPath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return markdownValidationParamError("--output", "unsafe output path: %s", err).WithCause(err)
}
return nil
},
@@ -67,7 +66,7 @@ var MarkdownFetch = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapMarkdownDownloadError(err)
}
defer resp.Body.Close()
@@ -75,7 +74,7 @@ var MarkdownFetch = common.Shortcut{
if outputPath == "" {
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapMarkdownDownloadError(err)
}
out := map[string]interface{}{
"file_token": fileToken,
@@ -93,7 +92,7 @@ var MarkdownFetch = common.Shortcut{
outputPath = filepath.Join(outputPath, fileName)
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return markdownValidationParamError("--output", "output file already exists: %s (use --overwrite to replace)", outputPath)
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
@@ -101,7 +100,7 @@ var MarkdownFetch = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)

View File

@@ -8,7 +8,6 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +29,7 @@ var MarkdownOverwrite = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := strings.TrimSpace(runtime.Str("file-token"))
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
return validateMarkdownSpec(runtime, markdownUploadSpec{
FileToken: fileToken,

View File

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -49,7 +48,7 @@ var MarkdownPatch = common.Shortcut{
}
if spec.Regex {
if _, err := regexp.Compile(spec.Pattern); err != nil {
return output.ErrValidation("invalid --pattern regex: %s", err)
return markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
}
}
return nil
@@ -122,7 +121,7 @@ var MarkdownPatch = common.Shortcut{
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapMarkdownDownloadError(err)
}
original := string(payload)
patched, matchCount, err := applyMarkdownPatch(original, spec)
@@ -192,16 +191,16 @@ func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return markdownValidationParamError("--file-token", "%s", err).WithCause(err)
}
if !runtime.Changed("pattern") {
return common.FlagErrorf("--pattern is required")
return markdownValidationParamError("--pattern", "--pattern is required")
}
if spec.Pattern == "" {
return output.ErrValidation("--pattern cannot be empty")
return markdownValidationParamError("--pattern", "--pattern cannot be empty")
}
if !spec.ContentSet {
return common.FlagErrorf("--content is required")
return markdownValidationParamError("--content", "--content is required")
}
return nil
}
@@ -212,7 +211,7 @@ func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, e
}
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
return "", 0, markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
}
matches := re.FindAllStringIndex(original, -1)
return re.ReplaceAllString(original, spec.Content), len(matches), nil

View File

@@ -9,7 +9,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,7 +27,7 @@ var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
@@ -38,7 +38,7 @@ func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (s
return id, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
@@ -104,7 +104,7 @@ func validateSheetRangeInput(sheetID, input string) error {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
return common.ValidationErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
@@ -127,7 +127,7 @@ func validateSingleCellRange(input string) error {
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
return common.ValidationErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
@@ -197,11 +197,11 @@ func matrixDimensions(values interface{}) (rows, cols int) {
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
return "", fmt.Errorf("invalid cell reference: %s", cell) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
return "", fmt.Errorf("invalid column: %s", matches[1]) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {

View File

@@ -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),

View File

@@ -11,8 +11,8 @@ import (
"os"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -38,7 +38,7 @@ var SheetWriteImage = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -91,7 +91,7 @@ var SheetWriteImage = common.Shortcut{
imageBytes, err := io.ReadAll(imageFile)
if err != nil {
return output.ErrValidation("cannot read image file: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
}
imageName := runtime.Str("name")
@@ -101,7 +101,7 @@ var SheetWriteImage = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"range": pointRange,
"image": imageBytes,
"name": imageName,
@@ -116,35 +116,35 @@ var SheetWriteImage = common.Shortcut{
func validateSheetWriteImageFile(fio fileio.FileIO, imagePath string) (fileio.FileInfo, error) {
if fio == nil {
return nil, output.ErrValidation("no file I/O provider registered")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no file I/O provider registered")
}
stat, err := fio.Stat(imagePath)
if err != nil {
return nil, wrapSheetWriteImageStatError(err, imagePath)
}
if stat.IsDir() || !stat.Mode().IsRegular() {
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image must be a regular file: %s", imagePath).WithParam("--image")
}
const maxImageSize int64 = 20 * 1024 * 1024
if stat.Size() > maxImageSize {
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024).WithParam("--image")
}
return stat, nil
}
func wrapSheetWriteImageStatError(err error, imagePath string) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
}
if os.IsNotExist(err) {
return output.ErrValidation("image file not found: %s", imagePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "image file not found: %s", imagePath).WithParam("--image").WithCause(err)
}
return output.ErrValidation("cannot stat image file: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot stat image file: %s", err).WithParam("--image").WithCause(err)
}
func wrapSheetWriteImageOpenError(err error) error {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe image path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
}
return output.ErrValidation("cannot read image file: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -15,40 +16,40 @@ import (
func validateBatchStyleData(raw string) error {
var data interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
}
arr, ok := data.([]interface{})
if !ok || len(arr) == 0 {
return common.FlagErrorf("--data must be a non-empty JSON array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a non-empty JSON array").WithParam("--data")
}
for i, item := range arr {
entry, ok := item.(map[string]interface{})
if !ok {
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d] must be an object with ranges and style", i).WithParam("--data")
}
rangesRaw, ok := entry["ranges"]
if !ok {
return common.FlagErrorf("--data[%d].ranges is required", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges is required", i).WithParam("--data")
}
ranges, ok := rangesRaw.([]interface{})
if !ok || len(ranges) == 0 {
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges must be a non-empty array of strings", i).WithParam("--data")
}
for j, r := range ranges {
s, ok := r.(string)
if !ok || s == "" {
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] must be a non-empty string", i, j).WithParam("--data")
}
if _, _, ok := splitSheetRange(s); !ok {
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s).WithParam("--data")
}
}
styleRaw, ok := entry["style"]
if !ok {
return common.FlagErrorf("--data[%d].style is required", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style is required", i).WithParam("--data")
}
if _, ok := styleRaw.(map[string]interface{}); !ok {
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style must be a JSON object", i).WithParam("--data")
}
}
return nil
@@ -74,14 +75,14 @@ var SheetSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
}
if _, ok := style.(map[string]interface{}); !ok {
return common.FlagErrorf("--style must be a JSON object, got %T", style)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be a JSON object, got %T", style).WithParam("--style")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -115,10 +116,10 @@ var SheetSetStyle = common.Shortcut{
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
}
data, err := runtime.CallAPI("PUT",
data, err := runtime.CallAPITyped("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -154,7 +155,7 @@ var SheetBatchSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return validateBatchStyleData(runtime.Str("data"))
},
@@ -181,11 +182,11 @@ var SheetBatchSetStyle = common.Shortcut{
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
}
normalizeBatchStyleRanges(data)
result, err := runtime.CallAPI("PUT",
result, err := runtime.CallAPITyped("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -242,7 +243,7 @@ var SheetMergeCells = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -271,7 +272,7 @@ var SheetMergeCells = common.Shortcut{
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -306,7 +307,7 @@ var SheetUnmergeCells = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
@@ -334,7 +335,7 @@ var SheetUnmergeCells = common.Shortcut{
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,7 +28,7 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -35,10 +36,10 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array of strings: %v", flagName, err).WithParam("--" + flagName)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array, got null", flagName).WithParam("--" + flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
@@ -53,12 +54,12 @@ func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges must not be empty").WithParam("--ranges")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s).WithParam("--ranges")
}
}
return ranges, nil
@@ -70,7 +71,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--condition-values must not be empty").WithParam("--condition-values")
}
dv := map[string]interface{}{
@@ -90,7 +91,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues)).WithParam("--colors")
}
opts["colors"] = colors
}
@@ -123,7 +124,7 @@ var SheetSetDropdown = common.Shortcut{
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
}
_, err := buildDropdownBody(runtime)
return err
@@ -147,7 +148,7 @@ var SheetSetDropdown = common.Shortcut{
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
data, err := runtime.CallAPITyped("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
@@ -214,7 +215,7 @@ var SheetUpdateDropdown = common.Shortcut{
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
data, err := runtime.CallAPITyped("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
@@ -247,7 +248,7 @@ var SheetGetDropdown = common.Shortcut{
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
}
return nil
},
@@ -259,7 +260,7 @@ var SheetGetDropdown = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
data, err := runtime.CallAPITyped("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
@@ -319,7 +320,7 @@ var SheetDeleteDropdown = common.Shortcut{
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
data, err := runtime.CallAPITyped("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -59,7 +59,7 @@ var SheetCreateFilterView = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range must not be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must not be empty").WithParam("--range")
}
return nil
},
@@ -85,7 +85,7 @@ var SheetCreateFilterView = common.Shortcut{
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
data, err := runtime.CallAPITyped("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
@@ -115,7 +115,7 @@ var SheetUpdateFilterView = common.Shortcut{
}
if !hasNonEmptyStringFlag(runtime, "range") &&
!hasNonEmptyStringFlag(runtime, "filter-view-name") {
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range or --filter-view-name")
}
return nil
},
@@ -141,7 +141,7 @@ var SheetUpdateFilterView = common.Shortcut{
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
data, err := runtime.CallAPITyped("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
@@ -174,7 +174,7 @@ var SheetListFilterViews = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
data, err := runtime.CallAPITyped("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
@@ -208,7 +208,7 @@ var SheetGetFilterView = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
data, err := runtime.CallAPITyped("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
@@ -242,7 +242,7 @@ var SheetDeleteFilterView = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
data, err := runtime.CallAPITyped("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
@@ -284,7 +284,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
data, err := runtime.CallAPITyped("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
@@ -317,7 +317,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
if !hasNonEmptyStringFlag(runtime, "filter-type") &&
!hasNonEmptyStringFlag(runtime, "compare-type") &&
!hasNonEmptyStringFlag(runtime, "expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
@@ -335,7 +335,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
data, err := runtime.CallAPI("PUT",
data, err := runtime.CallAPITyped("PUT",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, body)
if err != nil {
@@ -371,7 +371,7 @@ var SheetListFilterViewConditions = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
nil, nil)
if err != nil {
@@ -409,7 +409,7 @@ var SheetGetFilterViewCondition = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
@@ -447,7 +447,7 @@ var SheetDeleteFilterViewCondition = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE",
data, err := runtime.CallAPITyped("DELETE",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
@@ -464,7 +464,7 @@ func validateExpectedFlag(s string) error {
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expected must be a JSON array (e.g. [\"6\"]), got: %s", s).WithParam("--expected")
}
return nil
}

View File

@@ -8,8 +8,8 @@ import (
"fmt"
"path/filepath"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -115,10 +115,10 @@ var SheetMediaUpload = common.Shortcut{
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", nil, common.WrapInputStatError(err, "file not found")
return "", nil, common.WrapInputStatErrorTyped(err, "file not found")
}
if !stat.Mode().IsRegular() {
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
return filePath, stat, nil
}
@@ -131,7 +131,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -181,7 +181,7 @@ func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
}
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return token, nil
}
@@ -194,7 +194,7 @@ func validateFloatImageRange(sheetID, rangeVal string) error {
return err
}
if prefix, _, ok := splitSheetRange(rangeVal); ok && sheetID != "" && prefix != sheetID {
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range prefix %q does not match --sheet-id %q", prefix, sheetID).WithParam("--range")
}
return nil
}
@@ -206,7 +206,7 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
runtime.Cmd.Flags().Changed("offset-x") ||
runtime.Cmd.Flags().Changed("offset-y")
if !hasField {
return common.FlagErrorf("specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
}
return nil
}
@@ -214,22 +214,22 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
func validateFloatImageDims(runtime *common.RuntimeContext) error {
if runtime.Cmd.Flags().Changed("width") {
if v := runtime.Int("width"); v < 20 {
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be >= 20 pixels, got %d", v).WithParam("--width")
}
}
if runtime.Cmd.Flags().Changed("height") {
if v := runtime.Int("height"); v < 20 {
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be >= 20 pixels, got %d", v).WithParam("--height")
}
}
if runtime.Cmd.Flags().Changed("offset-x") {
if v := runtime.Int("offset-x"); v < 0 {
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-x must be >= 0, got %d", v).WithParam("--offset-x")
}
}
if runtime.Cmd.Flags().Changed("offset-y") {
if v := runtime.Int("offset-y"); v < 0 {
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-y must be >= 0, got %d", v).WithParam("--offset-y")
}
}
return nil
@@ -304,7 +304,7 @@ var SheetCreateFloatImage = common.Shortcut{
if s := runtime.Str("float-image-id"); s != "" {
body["float_image_id"] = s
}
data, err := runtime.CallAPI("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
data, err := runtime.CallAPITyped("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
@@ -353,7 +353,7 @@ var SheetUpdateFloatImage = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
body := buildFloatImageBody(runtime, false)
data, err := runtime.CallAPI("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
data, err := runtime.CallAPITyped("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
if err != nil {
return err
}
@@ -387,7 +387,7 @@ var SheetGetFloatImage = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
data, err := runtime.CallAPITyped("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}
@@ -420,7 +420,7 @@ var SheetListFloatImages = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
data, err := runtime.CallAPITyped("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
@@ -454,7 +454,7 @@ var SheetDeleteFloatImage = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFloatImageToken(runtime)
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
data, err := runtime.CallAPITyped("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
if err != nil {
return err
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,7 +32,7 @@ var SheetAddDimension = common.Shortcut{
}
length := runtime.Int("length")
if length < 1 || length > 5000 {
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--length must be between 1 and 5000, got %d", length).WithParam("--length")
}
return nil
},
@@ -51,7 +52,7 @@ var SheetAddDimension = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -91,10 +92,10 @@ var SheetInsertDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
}
if runtime.Int("end-index") <= runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be greater than --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be greater than --start-index").WithParam("--end-index")
}
return nil
},
@@ -131,7 +132,7 @@ var SheetInsertDimension = common.Shortcut{
body["inheritStyle"] = s
}
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
nil, body,
)
@@ -165,16 +166,16 @@ var SheetUpdateDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
}
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --visible or --fixed-size")
}
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
return common.FlagErrorf("--fixed-size must be >= 1")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fixed-size must be >= 1").WithParam("--fixed-size")
}
return nil
},
@@ -211,7 +212,7 @@ var SheetUpdateDimension = common.Shortcut{
props["fixedSize"] = runtime.Int("fixed-size")
}
data, err := runtime.CallAPI("PUT",
data, err := runtime.CallAPITyped("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
@@ -253,13 +254,13 @@ var SheetMoveDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
}
if runtime.Int("destination-index") < 0 {
return common.FlagErrorf("--destination-index must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--destination-index must be >= 0").WithParam("--destination-index")
}
return nil
},
@@ -281,7 +282,7 @@ var SheetMoveDimension = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token),
validate.EncodePathSegment(runtime.Str("sheet-id")),
@@ -324,10 +325,10 @@ var SheetDeleteDimension = common.Shortcut{
return err
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
}
return nil
},
@@ -348,7 +349,7 @@ var SheetDeleteDimension = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("DELETE",
data, err := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{

View File

@@ -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()

View File

@@ -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")
}
}

View File

@@ -5,11 +5,10 @@ package backward
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -21,63 +20,63 @@ func sheetBatchUpdatePath(token string) string {
}
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--spreadsheet-token").WithCause(err)
}
return token, nil
}
url := strings.TrimSpace(runtime.Str("url"))
if url == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/<token>").WithParam("--url")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--url").WithCause(err)
}
return token, nil
}
func validateSheetID(flagName, sheetID string) error {
if strings.TrimSpace(sheetID) == "" {
return common.FlagErrorf("specify --%s", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --%s", flagName).WithParam("--" + flagName)
}
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
return common.FlagErrorf("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
}
return nil
}
func validateSheetTitle(flagName, title string) error {
if title == "" {
return common.FlagErrorf("--%s must not be empty", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not be empty", flagName).WithParam("--" + flagName)
}
if strings.ContainsAny(title, "\t\r\n") {
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain tabs or line breaks", flagName).WithParam("--" + flagName)
}
if err := validate.RejectControlChars(title, flagName); err != nil {
return common.FlagErrorf("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
}
if len([]rune(title)) > 100 {
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be <= 100 characters", flagName).WithParam("--" + flagName)
}
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain any of / \\ ? * [ ] :", flagName).WithParam("--" + flagName)
}
return nil
}
func validateNonNegativeInt(flagName string, value int) error {
if value < 0 {
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be >= 0, got %d", flagName, value).WithParam("--" + flagName)
}
return nil
}
@@ -287,36 +286,18 @@ func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interfac
return out
}
func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} {
if len(overlay) == 0 {
return detail
}
if detail == nil {
return overlay
}
if existing, ok := detail.(map[string]interface{}); ok {
merged := map[string]interface{}{}
for k, v := range existing {
merged[k] = v
}
for k, v := range overlay {
merged[k] = v
}
return merged
}
merged := map[string]interface{}{}
for k, v := range overlay {
merged[k] = v
}
merged["cause_detail"] = detail
return merged
}
func copySheetMoveRetryCommand(token, sheetID string, index int) string {
return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index)
}
// wrapCopySheetMoveError reports a +copy-sheet that created the new sheet but
// then failed to move it to the requested index. The copy already succeeded, so
// the recovery is to retry only the move (not the whole +copy-sheet, which would
// duplicate the sheet) — that guard and the exact retry command go into the
// hint. The underlying move error is already a typed errs.* error from
// CallAPITyped; its category/subtype/code/log_id are preserved in place
// (mirroring drive's enrichDriveSearchError) so the failure stays accurately
// classified, with only the partial-success context folded into message and hint.
func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
if strings.TrimSpace(sheetID) == "" {
return err
@@ -329,46 +310,22 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
sheetID,
retryCommand,
)
detail := map[string]interface{}{
"partial_success": true,
"failed_step": "move_copied_sheet",
"spreadsheet_token": token,
"sheet_id": sheetID,
"requested_index": index,
"retry_command": retryCommand,
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = fmt.Sprintf("%s: %s", msg, upstream)
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" {
hint = upstreamHint + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message),
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
},
Err: err,
Raw: exitErr.Raw,
}
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Message: fmt.Sprintf("%s: %v", msg, err),
Hint: hint,
Detail: detail,
},
Err: err,
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).WithHint(hint).WithCause(err)
}
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
@@ -397,7 +354,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
}
if runtime.Changed("lock-info") {
if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil {
return common.FlagErrorf("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--lock-info").WithCause(err)
}
}
@@ -405,24 +362,24 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
if hasProtectConfig {
lock := runtime.Str("lock")
if !runtime.Changed("lock") {
return common.FlagErrorf("specify --lock when updating protection settings")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --lock when updating protection settings").WithParam("--lock")
}
if runtime.Changed("lock-info") && lock != "LOCK" {
return common.FlagErrorf("--lock-info requires --lock LOCK")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--lock-info requires --lock LOCK").WithParam("--lock-info")
}
if runtime.Changed("user-ids") {
if lock != "LOCK" {
return common.FlagErrorf("--user-ids requires --lock LOCK")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --lock LOCK").WithParam("--user-ids")
}
if runtime.Str("user-id-type") == "" {
return common.FlagErrorf("--user-ids requires --user-id-type")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --user-id-type").WithParam("--user-id-type")
}
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
if err != nil {
return err
}
if len(userIDs) == 0 {
return common.FlagErrorf("--user-ids must not be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids must not be empty").WithParam("--user-ids")
}
}
}
@@ -434,7 +391,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
runtime.Changed("frozen-col-count") ||
hasProtectConfig
if !hasUpdate {
return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
}
return nil
@@ -530,7 +487,7 @@ var SheetCreateSheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
if err != nil {
return err
}
@@ -593,7 +550,7 @@ var SheetCopySheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
if err != nil {
return err
}
@@ -604,7 +561,7 @@ var SheetCopySheet = common.Shortcut{
}
if runtime.Changed("index") {
copiedSheetID, _ := out["sheet_id"].(string)
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
moveResp, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
if err != nil {
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
}
@@ -644,7 +601,7 @@ var SheetDeleteSheet = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateSheetManageToken(runtime)
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
if err != nil {
return err
}
@@ -707,7 +664,7 @@ var SheetUpdateSheet = common.Shortcut{
params = map[string]interface{}{"user_id_type": userIDType}
}
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body)
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), params, body)
if err != nil {
return err
}

View File

@@ -13,8 +13,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,7 +36,7 @@ var SheetInfo = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
}
return nil
},
@@ -55,7 +55,7 @@ var SheetInfo = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
spreadsheetData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
if err != nil {
return err
}
@@ -95,13 +95,13 @@ var SheetCreate = common.Shortcut{
if headersStr := runtime.Str("headers"); headersStr != "" {
var headers []interface{}
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
}
}
if dataStr := runtime.Str("data"); dataStr != "" {
var rows [][]interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
}
}
return nil
@@ -129,7 +129,7 @@ var SheetCreate = common.Shortcut{
if headersStr != "" {
var headers []interface{}
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
}
if len(headers) > 0 {
allRows = append(allRows, any(headers))
@@ -139,7 +139,7 @@ var SheetCreate = common.Shortcut{
if dataStr != "" {
var rows []interface{}
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
}
if len(rows) > 0 {
allRows = append(allRows, rows...)
@@ -151,7 +151,7 @@ var SheetCreate = common.Shortcut{
createData["folder_token"] = folderToken
}
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
if err != nil {
return err
}
@@ -164,7 +164,7 @@ var SheetCreate = common.Shortcut{
if err != nil {
return err
}
if _, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
if _, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
"range": appendRange,
"values": allRows,
@@ -211,8 +211,11 @@ var SheetExport = common.Shortcut{
if _, err := validateSheetManageToken(runtime); err != nil {
return err
}
if err := validateSheetExportOutputPath(runtime); err != nil {
return err
}
if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sheet-id is required when --file-extension is csv").WithParam("--sheet-id")
}
return nil
},
@@ -238,10 +241,8 @@ var SheetExport = common.Shortcut{
outputPath := runtime.Str("output-path")
sheetID := runtime.Str("sheet-id")
if outputPath != "" {
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := validateSheetExportOutputPath(runtime); err != nil {
return err
}
exportData := map[string]interface{}{
@@ -253,7 +254,7 @@ var SheetExport = common.Shortcut{
exportData["sub_id"] = sheetID
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
if err != nil {
return err
}
@@ -280,7 +281,7 @@ var SheetExport = common.Shortcut{
}
if fileToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "export task timed out").WithRetryable()
}
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
@@ -298,7 +299,7 @@ var SheetExport = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapSheetsNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()
@@ -307,7 +308,7 @@ var SheetExport = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return common.WrapSaveErrorTyped(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
@@ -321,3 +322,14 @@ var SheetExport = common.Shortcut{
return nil
},
}
func validateSheetExportOutputPath(runtime *common.RuntimeContext) error {
outputPath := strings.TrimSpace(runtime.Str("output-path"))
if outputPath == "" {
return nil
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output-path").WithCause(err)
}
return nil
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import "github.com/larksuite/cli/errs"
// wrapSheetsNetworkErr preserves typed boundary errors and only classifies raw
// transport failures that still surface from stream/download paths.
func wrapSheetsNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

View File

@@ -198,7 +198,7 @@ var batchOpDispatch = map[string]batchOpMapping{
// turned into a file_token. Callers must pass --image-token / --image-uri.
func rejectLocalImageInBatch(fv flagView) error {
if strings.TrimSpace(fv.Str("image")) != "" {
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
return common.ValidationErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
}
return nil
}
@@ -208,23 +208,23 @@ func rejectLocalImageInBatch(fv flagView) error {
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if sheetID == "" {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
}
if !fv.Changed("source-index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
}
if fv.Int("source-index") < 0 {
return nil, common.FlagErrorf("--source-index must be >= 0")
return nil, common.ValidationErrorf("--source-index must be >= 0")
}
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
// which would silently move the sheet to the front. Require it explicitly so
// the batch contract matches the standalone one.
if !fv.Changed("index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
return nil, common.ValidationErrorf("+sheet-move in +batch-update requires index")
}
if fv.Int("index") < 0 {
return nil, common.FlagErrorf("--index must be >= 0")
return nil, common.ValidationErrorf("--index must be >= 0")
}
return map[string]interface{}{
"excel_id": token,
@@ -254,19 +254,19 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
return nil, common.ValidationErrorf("operations[%d] must be a JSON object", index)
}
scRaw, present := op["shortcut"]
if !present {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' field is required", index)
}
sc, ok := scRaw.(string)
if !ok || sc == "" {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
}
mapping, ok := batchOpDispatch[sc]
if !ok {
return nil, common.FlagErrorf(
return nil, common.ValidationErrorf(
"operations[%d]: shortcut %q not allowed in +batch-update "+
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
@@ -280,12 +280,12 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
} else {
input, ok = inputRaw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
return nil, common.ValidationErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
}
}
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
if _, has := input["operation"]; has {
return nil, common.FlagErrorf(
return nil, common.ValidationErrorf(
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
index, sc,
)
@@ -293,7 +293,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
for _, k := range reservedSubOpKeys {
if _, has := input[k]; has {
return nil, common.FlagErrorf(
return nil, common.ValidationErrorf(
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
index, sc, k,
)
@@ -302,7 +302,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// 拒绝任何额外的 sub-op 顶层 key防御未来 schema drift / 用户笔误)。
for k := range op {
if k != "shortcut" && k != "input" {
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
return nil, common.ValidationErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
}
}
fv := newMapFlagViewForCommand(sc, input)
@@ -310,14 +310,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// sub-op's scalar fields here before the translator reads them via
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
if err := fv.validateRawTypes(); err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
return nil, common.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
}
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
body, err := mapping.translate(fv, token, sheetID, sheetName)
if err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
return nil, common.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
}
return map[string]interface{}{
"tool_name": mapping.mcpToolName,
@@ -328,7 +328,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
return nil, common.ValidationErrorf("--operations must be a non-empty JSON array")
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {

View File

@@ -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

View File

@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
@@ -453,16 +453,15 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
exitErr, ok := err.(*output.ExitError)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
t.Fatalf("error type = %T, want typed problem", err)
}
if exitErr.Detail == nil {
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
if !strings.Contains(p.Message, "shtNEW") {
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["spreadsheet_token"] != "shtNEW" {
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
if !strings.Contains(p.Hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
}
}

View File

@@ -95,7 +95,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
return common.FlagErrorf("--%s: %s", name, vErr.Error())
return common.ValidationErrorf("--%s: %s", name, vErr.Error())
}
return nil
}

View File

@@ -12,20 +12,40 @@ import (
"encoding/json"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func sheetsFlagParam(name string) string {
if strings.HasPrefix(name, "--") {
return name
}
return "--" + name
}
func sheetsInvalidParam(name, reason string) errs.InvalidParam {
return errs.InvalidParam{Name: sheetsFlagParam(name), Reason: reason}
}
func sheetsValidationForFlag(name, format string, args ...any) *errs.ValidationError {
return common.ValidationErrorf(format, args...).WithParam(sheetsFlagParam(name))
}
func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationError {
return common.ValidationErrorf("%v", cause).WithParam(sheetsFlagParam(name)).WithCause(cause)
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
// pair shared by every sheets canonical shortcut and returns the resolved
// token. Network-free, safe to call from Validate and DryRun.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
}
return token, nil
}
@@ -33,10 +53,10 @@ func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", common.FlagErrorf("%v", err)
return "", sheetsValidationCauseForFlag("url", err)
}
return token, nil
}
@@ -64,18 +84,18 @@ func extractSpreadsheetToken(input string) string {
// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers
// pass both through to the tool input; the server picks whichever fits.
func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) {
if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil {
if err := common.ExactlyOneTyped(runtime, "sheet-id", "sheet-name"); err != nil {
return "", "", err
}
if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" {
if err := validate.RejectControlChars(id, "sheet-id"); err != nil {
return "", "", common.FlagErrorf("%v", err)
return "", "", sheetsValidationCauseForFlag("sheet-id", err)
}
return id, "", nil
}
name := strings.TrimSpace(runtime.Str("sheet-name"))
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
return "", "", common.FlagErrorf("%v", err)
return "", "", sheetsValidationCauseForFlag("sheet-name", err)
}
return "", name, nil
}
@@ -116,18 +136,26 @@ func requireSheetSelector(sheetID, sheetName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID == "" && sheetName == "" {
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name").
WithParams(
sheetsInvalidParam("sheet-id", "required; specify at least one"),
sheetsInvalidParam("sheet-name", "required; specify at least one"),
)
}
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag("sheet-id", err)
}
} else {
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag("sheet-name", err)
}
}
return nil
@@ -152,15 +180,19 @@ func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string)
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
return common.ValidationErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName).
WithParams(
sheetsInvalidParam(idFlagName, "mutually exclusive"),
sheetsInvalidParam(nameFlagName, "mutually exclusive"),
)
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag(idFlagName, err)
}
} else if sheetName != "" {
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
return common.FlagErrorf("%v", err)
return sheetsValidationCauseForFlag(nameFlagName, err)
}
}
return nil
@@ -197,7 +229,7 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
@@ -216,11 +248,11 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
return nil, sheetsValidationForFlag(name, "--%s is required", name)
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON object", name)
return nil, sheetsValidationForFlag(name, "--%s must be a JSON object", name)
}
return m, nil
}
@@ -232,11 +264,11 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
return nil, sheetsValidationForFlag(name, "--%s is required", name)
}
a, ok := v.([]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON array", name)
return nil, sheetsValidationForFlag(name, "--%s must be a JSON array", name)
}
return a, nil
}
@@ -293,7 +325,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--border-styles must be a JSON object")
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
return m, nil
}
@@ -307,5 +339,10 @@ func requireAnyStyleFlag(runtime flagView) error {
if runtime.Str("border-styles") != "" {
return nil
}
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
WithParams(
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
)
}

View File

@@ -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:
//

View File

@@ -132,7 +132,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--operations is required")
return nil, common.ValidationErrorf("--operations is required")
}
if arr, ok := v.([]interface{}); ok {
return arr, nil
@@ -142,7 +142,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
return ops, nil
}
}
return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
return nil, common.ValidationErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
}
// CellsBatchSetStyle stamps one style block across many sheet-prefixed
@@ -222,7 +222,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
}
rows, cols, err := rangeDimensions(sub)
if err != nil {
return nil, common.FlagErrorf("range %q: %v", rng, err)
return nil, common.ValidationErrorf("range %q: %v", rng, err)
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
@@ -386,7 +386,7 @@ var DropdownDelete = common.Shortcut{
return err
}
if len(ranges) > 100 {
return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
return common.ValidationErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
@@ -439,7 +439,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
}
rows, cols, err := rangeDimensions(sub)
if err != nil {
return nil, common.FlagErrorf("range %q: %v", rng, err)
return nil, common.ValidationErrorf("range %q: %v", rng, err)
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
@@ -471,21 +471,21 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
return nil, common.ValidationErrorf("--ranges[%d] must be a string", i)
}
s = strings.TrimSpace(s)
if !strings.Contains(s, "!") {
return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
return nil, common.ValidationErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
}
// Validate the sheet!range shape up front so malformed entries like
// "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail
// here at Validate instead of slipping through to DryRun/Execute.
_, sub, err := splitSheetPrefixedRange(s)
if err != nil {
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
return nil, common.ValidationErrorf("--ranges[%d]: %v", i, err)
}
if _, _, err := rangeDimensions(sub); err != nil {
return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err)
return nil, common.ValidationErrorf("--ranges[%d] (%q): %v", i, s, err)
}
out = append(out, s)
}
@@ -496,7 +496,7 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) {
idx := strings.Index(rng, "!")
if idx <= 0 || idx == len(rng)-1 {
return "", "", common.FlagErrorf("range %q must use sheet!range form", rng)
return "", "", common.ValidationErrorf("range %q must use sheet!range form", rng)
}
return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil
}

View File

@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -251,7 +251,7 @@ func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec
return nil, err
}
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
return nil, common.FlagErrorf("--%s is required", spec.idFlag)
return nil, common.ValidationErrorf("--%s is required", spec.idFlag)
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
@@ -335,7 +335,7 @@ func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec
return nil, err
}
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
return nil, common.FlagErrorf("--%s is required", spec.idFlag)
return nil, common.ValidationErrorf("--%s is required", spec.idFlag)
}
input := map[string]interface{}{
"excel_id": token,
@@ -517,16 +517,16 @@ func validateSparklineUpdateItems(input map[string]interface{}) error {
}
arr, ok := raw.([]interface{})
if !ok {
return common.FlagErrorf("+sparkline-update properties.sparklines must be an array")
return common.ValidationErrorf("+sparkline-update properties.sparklines must be an array")
}
for i, item := range arr {
m, _ := item.(map[string]interface{})
if m == nil {
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
}
id, _ := m["sparkline_id"].(string)
if strings.TrimSpace(id) == "" {
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
}
}
return nil
@@ -595,20 +595,20 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
}
}
if set == 0 && requireImageSource {
return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required")
return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required")
}
if set > 1 {
return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive")
return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive")
}
name := floatImageName(runtime)
if name == "" {
return nil, common.FlagErrorf("--image-name is required")
return nil, common.ValidationErrorf("--image-name is required")
}
if !runtime.Changed("position-row") || !runtime.Changed("position-col") {
return nil, common.FlagErrorf("--position-row and --position-col are required")
return nil, common.ValidationErrorf("--position-row and --position-col are required")
}
if !runtime.Changed("size-width") || !runtime.Changed("size-height") {
return nil, common.FlagErrorf("--size-width and --size-height are required")
return nil, common.ValidationErrorf("--size-width and --size-height are required")
}
props := map[string]interface{}{
"image_name": name,
@@ -626,7 +626,9 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
// Local file: validate path safety here so --dry-run also rejects
// unsafe paths; Execute uploads it and passes the real token in.
if _, err := validate.SafeLocalFlagPath("--image", img); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
}
if uploadedImageToken != "" {
props["image_token"] = uploadedImageToken
@@ -746,7 +748,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
}
info, err := runtime.FileIO().Stat(img)
if err != nil {
return "", common.WrapInputStatError(err)
return "", common.WrapInputStatErrorTyped(err)
}
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: img,
@@ -762,7 +764,7 @@ func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string
return nil, err
}
if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" {
return nil, common.FlagErrorf("--float-image-id is required")
return nil, common.ValidationErrorf("--float-image-id is required")
}
props, err := floatImageProperties(runtime, uploadedImageToken, op == "create")
if err != nil {
@@ -882,7 +884,7 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
props := map[string]interface{}{
"range": strings.TrimSpace(runtime.Str("range")),
@@ -957,10 +959,10 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if sheetID == "" {
return nil, common.FlagErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
return nil, common.ValidationErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
@@ -1031,7 +1033,7 @@ func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[
return nil, err
}
if sheetID == "" {
return nil, common.FlagErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
return nil, common.ValidationErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
}
input := map[string]interface{}{
"excel_id": token,

View File

@@ -5,10 +5,9 @@ package sheets
import (
"context"
"errors"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -76,7 +75,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -108,22 +107,22 @@ func normalizeClearType(scope string) string {
// pivot-occupied A1 with cells-clear; point the agent at the object's own
// delete command instead. Non-matching errors pass through untouched.
func annotateEmbeddedBlockClearErr(err error) error {
var ee *output.ExitError
if !errors.As(err, &ee) || ee.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") {
if !strings.Contains(strings.ToLower(p.Message), "embedded block") {
return err
}
const hint = "the range overlaps an embedded object (pivot table / chart); " +
"cells-clear only clears cell values/formats and cannot delete it — " +
"delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)"
if ee.Detail.Hint == "" {
ee.Detail.Hint = hint
if p.Hint == "" {
p.Hint = hint
} else {
ee.Detail.Hint += "; " + hint
p.Hint += "; " + hint
}
return ee
return err
}
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
@@ -191,7 +190,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -345,36 +344,36 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
return nil, err
}
if !runtime.Changed("range") {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
}
if parsedDim != dimension {
want := "row numbers (e.g. \"2:10\")"
if dimension == "column" {
want = "column letters (e.g. \"A:E\")"
}
return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
return nil, common.ValidationErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
}
if !strings.Contains(rangeStr, ":") {
rangeStr = rangeStr + ":" + rangeStr
}
typ := strings.TrimSpace(runtime.Str("type"))
if typ == "" {
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
return nil, common.ValidationErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
}
if dimension == "column" && typ == "auto" {
return nil, common.FlagErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
return nil, common.ValidationErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
}
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
if typ == "pixel" && !hasSize {
return nil, common.FlagErrorf("--type pixel requires --size <px>")
return nil, common.ValidationErrorf("--type pixel requires --size <px>")
}
if typ != "pixel" && hasSize {
return nil, common.FlagErrorf("--size is only valid with --type pixel")
return nil, common.ValidationErrorf("--size is only valid with --type pixel")
}
input := map[string]interface{}{
"excel_id": token,
@@ -567,10 +566,10 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.FlagErrorf("--source-range is required")
return nil, common.ValidationErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.FlagErrorf("--target-range is required")
return nil, common.ValidationErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -609,10 +608,10 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.FlagErrorf("--source-range is required")
return nil, common.ValidationErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.FlagErrorf("--target-range is required")
return nil, common.ValidationErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -641,7 +640,7 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
// requireJSONArray runs the embedded JSON Schema for --sort-keys
// via parseJSONFlag → validateParsedJSONFlag, so each item is

View File

@@ -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)
}
})

View File

@@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
return nil
},
@@ -142,7 +142,7 @@ var CsvGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
return nil
},
@@ -484,7 +484,7 @@ var DropdownGet = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
return nil
},

View File

@@ -36,7 +36,7 @@ var CellsSearch = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("find")) == "" {
return common.FlagErrorf("--find is required")
return common.ValidationErrorf("--find is required")
}
return nil
},
@@ -151,10 +151,10 @@ func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[strin
return nil, err
}
if strings.TrimSpace(runtime.Str("find")) == "" {
return nil, common.FlagErrorf("--find is required")
return nil, common.ValidationErrorf("--find is required")
}
if !runtime.Changed("replacement") {
return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)")
return nil, common.ValidationErrorf("--replacement is required (pass an empty string to delete matches)")
}
input := map[string]interface{}{
"excel_id": token,

View File

@@ -164,18 +164,18 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if !runtime.Changed("position") {
return nil, common.FlagErrorf("--position is required")
return nil, common.ValidationErrorf("--position is required")
}
if !runtime.Changed("count") {
return nil, common.FlagErrorf("--count is required")
return nil, common.ValidationErrorf("--count is required")
}
position := strings.TrimSpace(runtime.Str("position"))
if _, _, err := parseA1Position(position); err != nil {
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
return nil, common.ValidationErrorf("invalid --position %q: %v", position, err)
}
count := runtime.Int("count")
if count <= 0 {
return nil, common.FlagErrorf("--count must be > 0 (got %d)", count)
return nil, common.ValidationErrorf("--count must be > 0 (got %d)", count)
}
input := map[string]interface{}{
"excel_id": token,
@@ -326,13 +326,13 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[str
return nil, err
}
if !runtime.Changed("dimension") {
return nil, common.FlagErrorf("--dimension is required")
return nil, common.ValidationErrorf("--dimension is required")
}
if !runtime.Changed("count") {
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
return nil, common.ValidationErrorf("--count is required (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, common.FlagErrorf("--count must be >= 0")
return nil, common.ValidationErrorf("--count must be >= 0")
}
dim := runtime.Str("dimension")
count := runtime.Int("count")
@@ -361,11 +361,11 @@ func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (ma
return nil, err
}
if !runtime.Changed("range") {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if _, _, _, err := parseA1Range(rangeStr); err != nil {
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
}
input := map[string]interface{}{
"excel_id": token,
@@ -611,7 +611,7 @@ var DimMove = common.Shortcut{
}
sheetID = lookedID
}
data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
data, err := runtime.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
if err != nil {
return err
}
@@ -632,20 +632,20 @@ type dimMovePlan struct {
// target dimension matches the source. Used by both Validate and Execute.
func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) {
if !runtime.Changed("source-range") || !runtime.Changed("target") {
return nil, common.FlagErrorf("--source-range and --target are required")
return nil, common.ValidationErrorf("--source-range and --target are required")
}
src := strings.TrimSpace(runtime.Str("source-range"))
dim, startIdx, endIdx, err := parseA1Range(src)
if err != nil {
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
return nil, common.ValidationErrorf("invalid --source-range %q: %v", src, err)
}
tgt := strings.TrimSpace(runtime.Str("target"))
tgtDim, tgtIdx, err := parseA1Position(tgt)
if err != nil {
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
return nil, common.ValidationErrorf("invalid --target %q: %v", tgt, err)
}
if tgtDim != dim {
return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
}
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
}

View File

@@ -13,9 +13,9 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -122,13 +122,13 @@ var SheetCreate = common.Shortcut{
func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) {
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.FlagErrorf("--title is required")
return nil, common.ValidationErrorf("--title is required")
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.FlagErrorf("--row-count must be between 0 and 50000")
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
if n := runtime.Int("col-count"); n < 0 || n > 200 {
return nil, common.FlagErrorf("--col-count must be between 0 and 200")
return nil, common.ValidationErrorf("--col-count must be between 0 and 200")
}
input := map[string]interface{}{
"excel_id": token,
@@ -167,7 +167,7 @@ func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[s
return nil, err
}
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.FlagErrorf("--title is required")
return nil, common.ValidationErrorf("--title is required")
}
input := map[string]interface{}{
"excel_id": token,
@@ -192,7 +192,7 @@ func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) (
return nil, err
}
if !runtime.Changed("color") {
return nil, common.FlagErrorf("--color is required (empty string clears)")
return nil, common.ValidationErrorf("--color is required (empty string clears)")
}
input := map[string]interface{}{
"excel_id": token,
@@ -311,13 +311,13 @@ var SheetMove = common.Shortcut{
return err
}
if !runtime.Changed("index") {
return common.FlagErrorf("--index is required")
return common.ValidationErrorf("--index is required")
}
if runtime.Int("index") < 0 {
return common.FlagErrorf("--index must be >= 0")
return common.ValidationErrorf("--index must be >= 0")
}
if runtime.Changed("source-index") && runtime.Int("source-index") < 0 {
return common.FlagErrorf("--source-index must be >= 0")
return common.ValidationErrorf("--source-index must be >= 0")
}
return nil
},
@@ -561,7 +561,7 @@ var WorkbookCreate = common.Shortcut{
Flags: flagsFor("+workbook-create"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("title")) == "" {
return common.FlagErrorf("--title is required")
return common.ValidationErrorf("--title is required")
}
if runtime.Str("headers") != "" {
v, err := parseJSONFlag(runtime, "headers")
@@ -569,7 +569,7 @@ var WorkbookCreate = common.Shortcut{
return err
}
if _, ok := v.([]interface{}); !ok {
return common.FlagErrorf("--headers must be a JSON array")
return common.ValidationErrorf("--headers must be a JSON array")
}
}
if runtime.Str("values") != "" {
@@ -579,11 +579,11 @@ var WorkbookCreate = common.Shortcut{
}
rows, ok := v.([]interface{})
if !ok {
return common.FlagErrorf("--values must be a JSON 2D array")
return common.ValidationErrorf("--values must be a JSON 2D array")
}
for i, r := range rows {
if _, ok := r.([]interface{}); !ok {
return common.FlagErrorf("--values[%d] must be an array", i)
return common.ValidationErrorf("--values[%d] must be an array", i)
}
}
}
@@ -613,7 +613,7 @@ var WorkbookCreate = common.Shortcut{
if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" {
body["folder_token"] = v
}
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
if err != nil {
return err
}
@@ -623,7 +623,7 @@ var WorkbookCreate = common.Shortcut{
token = common.GetString(ss, "token")
}
if token == "" {
return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response")
}
result := map[string]interface{}{"spreadsheet": ss}
@@ -665,19 +665,9 @@ var WorkbookCreate = common.Shortcut{
// not. The new spreadsheet_token is surfaced in the error detail so callers can
// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only
// finding the token interpolated into a bare error string.
func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_success",
Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason),
Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it",
Detail: map[string]interface{}{
"spreadsheet_token": token,
"spreadsheet": spreadsheet,
},
},
}
func workbookCreatedButFillFailed(token string, _ interface{}, reason string) error {
return errs.NewInternalError(errs.SubtypeSDKError, "spreadsheet %s created but %s", token, reason).
WithHint("the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it")
}
// buildInitialFillInput zips --headers + --values into a single set_cell_range
@@ -765,7 +755,7 @@ var WorkbookExport = common.Shortcut{
ext = "xlsx"
}
if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
return common.FlagErrorf("--sheet-id is required when --file-extension=csv")
return common.ValidationErrorf("--sheet-id is required when --file-extension=csv")
}
return nil
},
@@ -813,13 +803,13 @@ var WorkbookExport = common.Shortcut{
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
taskData, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return err
}
ticket := common.GetString(taskData, "ticket")
if ticket == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket missing")
}
result := map[string]interface{}{
@@ -847,9 +837,9 @@ var WorkbookExport = common.Shortcut{
continue
default: // any non-zero status outside the in-progress window is a failure
if status.JobErrorMsg != "" {
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg)
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed: %s", ticket, status.JobErrorMsg)
}
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed with job_status=%d", ticket, status.JobStatus)
}
}
if fileToken == "" {
@@ -887,7 +877,7 @@ type exportTaskStatus struct {
}
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
@@ -898,7 +888,7 @@ func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (expor
}
result := common.GetMap(data, "result")
if result == nil {
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
return exportTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "export task %s: empty result", ticket)
}
js, _ := util.ToFloat64(result["job_status"])
fs, _ := util.ToFloat64(result["file_size"])
@@ -918,10 +908,10 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return "", output.ErrNetwork("download failed: %s", err)
return "", sheetsDownloadRequestError(err)
}
if apiResp.StatusCode >= 400 {
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
return "", sheetsDownloadHTTPStatusError(apiResp)
}
target := outPath
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
@@ -935,7 +925,7 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
ContentType: apiResp.Header.Get("Content-Type"),
ContentLength: int64(len(apiResp.RawBody)),
}, strings.NewReader(string(apiResp.RawBody))); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
return "", common.WrapSaveErrorTyped(err)
}
resolved, _ := runtime.FileIO().ResolvePath(target)
if resolved == "" {
@@ -944,6 +934,57 @@ func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fil
return resolved, nil
}
func sheetsDownloadRequestError(err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
}
func sheetsDownloadHTTPStatusError(resp *larkcore.ApiResp) error {
status := resp.StatusCode
body := strings.TrimSpace(string(resp.RawBody))
if body == "" {
body = http.StatusText(status)
}
logID := sheetsDownloadResponseLogID(resp)
if status >= http.StatusInternalServerError {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d: %s", status, body).
WithCode(status).
WithRetryable()
if logID != "" {
err = err.WithLogID(logID)
}
return err
}
if status == http.StatusTooManyRequests {
err := errs.NewAPIError(errs.SubtypeRateLimit, "download failed: HTTP %d: %s", status, body).
WithCode(status).
WithRetryable()
if logID != "" {
err = err.WithLogID(logID)
}
return err
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
err := errs.NewAPIError(subtype, "download failed: HTTP %d: %s", status, body).WithCode(status)
if logID != "" {
err = err.WithLogID(logID)
}
return err
}
func sheetsDownloadResponseLogID(resp *larkcore.ApiResp) string {
logID := strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(resp.Header.Get(larkcore.HttpHeaderKeyRequestId))
}
return logID
}
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
// id + current 0-based index. Caller is responsible for ensuring at least one
// of sheetID/sheetName is non-empty.
@@ -956,7 +997,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
}
m, ok := out.(map[string]interface{})
if !ok {
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
}
sheets, _ := m["sheets"].([]interface{})
for _, raw := range sheets {
@@ -975,7 +1016,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) {
idx, ok := util.ToFloat64(sm["index"])
if !ok {
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field")
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field")
}
return id, int(idx), nil
}
@@ -984,7 +1025,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
if target == "" {
target = sheetName
}
return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target))
return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target)
}
// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the
@@ -1001,7 +1042,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
m, ok := out.(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
}
sheets, _ := m["sheets"].([]interface{})
bestID := ""
@@ -1029,7 +1070,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
}
if bestID == "" {
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
}
return bestID, nil
}

View File

@@ -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.

View File

@@ -15,7 +15,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -82,7 +82,7 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
cells, err := requireJSONArray(runtime, "cells")
if err != nil {
@@ -156,11 +156,11 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
@@ -218,6 +218,7 @@ var CsvPut = common.Shortcut{
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("start-cell", "range")
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
},
Validate: validateViaInput(csvPutInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -300,7 +301,10 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
return nil, err
}
if strings.TrimSpace(runtime.Str("csv")) == "" {
return nil, common.FlagErrorf("--csv is required")
return nil, common.ValidationErrorf("--csv is required")
}
if runtime.Changed("start-cell") && runtime.Changed("range") {
return nil, common.ValidationErrorf("--start-cell and --range are mutually exclusive")
}
anchor := strings.TrimSpace(runtime.Str("start-cell"))
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
@@ -311,23 +315,24 @@ func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string
// collapses to its top-left cell; +csv-put pastes from the anchor and
// auto-expands, so the range's lower-right bound is irrelevant.
//
// Standalone enforces "one of --start-cell / --range" via cobra's
// MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs
// cobra, so without an explicit check the default "A1" silently wins and the
// paste lands at A1 instead of failing like the standalone command. Mirror
// the standalone contract: when --start-cell is absent, --range is mandatory.
// Standalone enforces exactly one of --start-cell / --range via cobra's
// flag groups (see PostMount). A +batch-update sub-op never runs cobra, so
// without explicit checks the default "A1" silently wins and the paste lands
// at A1 instead of failing like the standalone command. Mirror the
// standalone contract: double-set is invalid, and when --start-cell is
// absent, --range is mandatory.
if !runtime.Changed("start-cell") {
rng := strings.TrimSpace(runtime.Str("range"))
if rng == "" {
return nil, common.FlagErrorf("--start-cell or --range is required")
return nil, common.ValidationErrorf("--start-cell or --range is required")
}
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
}
if anchor == "" {
return nil, common.FlagErrorf("--start-cell is required")
return nil, common.ValidationErrorf("--start-cell is required")
}
if _, _, ok := splitCellRef(anchor); !ok {
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
return nil, common.ValidationErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
}
input := map[string]interface{}{
"excel_id": token,
@@ -398,11 +403,11 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.FlagErrorf("--range is required")
return nil, common.ValidationErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
@@ -461,7 +466,7 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
return nil, err
}
if len(colors) > sourceSize {
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
return nil, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
dv["highlight_colors"] = colors
}
@@ -483,9 +488,9 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
switch {
case optsRaw != "" && sourceRange != "":
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
return 0, nil, common.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one")
case optsRaw == "" && sourceRange == "":
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
case optsRaw != "":
options, err := requireJSONArray(runtime, "options")
if err != nil {
@@ -498,7 +503,7 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
default: // sourceRange != ""
rows, cols, err := rangeDimensions(sourceRange)
if err != nil {
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
return 0, nil, common.ValidationErrorf("--source-range %q: %v", sourceRange, err)
}
return rows * cols, map[string]interface{}{
"type": "listFromRange",
@@ -523,7 +528,7 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
return 0, err
}
if len(colors) > sourceSize {
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
return 0, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
}
return sourceSize, nil
@@ -696,18 +701,18 @@ var CellsSetImage = common.Shortcut{
}
r := strings.TrimSpace(runtime.Str("range"))
if r == "" {
return common.FlagErrorf("--range is required")
return common.ValidationErrorf("--range is required")
}
rows, cols, err := rangeDimensions(r)
if err != nil {
return common.FlagErrorf("--range %q: %v", r, err)
return common.ValidationErrorf("--range %q: %v", r, err)
}
if rows != 1 || cols != 1 {
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
return common.ValidationErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
}
imgPath := strings.TrimSpace(runtime.Str("image"))
if imgPath == "" {
return common.FlagErrorf("--image is required")
return common.ValidationErrorf("--image is required")
}
// Validate path safety here (not just at Execute) so --dry-run also
// rejects unsafe paths instead of giving a false-positive preview.
@@ -715,7 +720,9 @@ var CellsSetImage = common.Shortcut{
// not existence, so legitimate relative paths still dry-run cleanly;
// the Execute-time Stat below still reports a missing/unreadable file.
if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
}
return nil
},
@@ -771,16 +778,18 @@ var CellsSetImage = common.Shortcut{
}
info, err := runtime.FileIO().Stat(imgPath)
if err != nil {
return common.WrapInputStatError(err)
return common.WrapInputStatErrorTyped(err)
}
imgFile, err := runtime.FileIO().Open(imgPath)
if err != nil {
return common.WrapInputStatError(err)
return common.WrapInputStatErrorTyped(err)
}
imgCfg, _, err := image.DecodeConfig(imgFile)
imgFile.Close()
if err != nil {
return fmt.Errorf("decode image dimensions: %w", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "decode image dimensions: %s", err).
WithParam("--image").
WithCause(err)
}
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: imgPath,
@@ -809,7 +818,7 @@ var CellsSetImage = common.Shortcut{
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
if err != nil {
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
return wrapCellsSetImageWriteError(err, fileToken)
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
@@ -822,3 +831,18 @@ var CellsSetImage = common.Shortcut{
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
},
}
func wrapCellsSetImageWriteError(err error, fileToken string) error {
hint := fmt.Sprintf("image was uploaded as file_token=%s; retry only the cell write with that token or remove the uploaded media", fileToken)
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint += "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "image uploaded (file_token=%s) but cell write failed: %s", fileToken, err).
WithHint(hint).
WithCause(err)
}

View File

@@ -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
}

View File

@@ -9,7 +9,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +30,7 @@ type presentationRef struct {
func parsePresentationRef(input string) (presentationRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--presentation cannot be empty").WithParam("--presentation")
}
// URL inputs: parse properly and only honor /slides/ or /wiki/ when they
// appear as a prefix of the URL path. Substring matching previously let
@@ -38,7 +38,7 @@ func parsePresentationRef(input string) (presentationRef, error) {
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
if token, ok := tokenAfterPathPrefix(u.Path, "/slides/"); ok {
return presentationRef{Kind: "slides", Token: token}, nil
@@ -46,13 +46,13 @@ func parsePresentationRef(input string) (presentationRef, error) {
if token, ok := tokenAfterPathPrefix(u.Path, "/wiki/"); ok {
return presentationRef{Kind: "wiki", Token: token}, nil
}
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
// Non-URL input must be a bare token — anything with path/query/fragment
// chars is rejected so partial-path inputs like `tmp/wiki/wikcn123` don't
// get silently accepted.
if strings.ContainsAny(raw, "/?#") {
return presentationRef{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
return presentationRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw).WithParam("--presentation")
}
return presentationRef{Kind: "slides", Token: raw}, nil
}
@@ -82,7 +82,7 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
case "slides":
return ref.Token, nil
case "wiki":
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
@@ -95,14 +95,14 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "slides" {
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slides shortcuts require a slides presentation", objType).WithParam("--presentation")
}
return objToken, nil
default:
return "", output.ErrValidation("unsupported presentation ref kind %q", ref.Kind)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported presentation ref kind %q", ref.Kind)
}
}
@@ -191,7 +191,7 @@ var xmlIdAttrRegex = regexp.MustCompile(`(?s)(?:^|\s)id\s*=\s*(["'])(.*?)(["'])`
func ensureXMLRootID(xmlFragment, want string) (string, error) {
m := xmlRootOpenTagRegex.FindStringSubmatchIndex(xmlFragment)
if m == nil {
return "", fmt.Errorf("no root element found in XML fragment")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "no root element found in XML fragment")
}
prefix := xmlFragment[m[2]:m[3]]
tagName := xmlFragment[m[4]:m[5]]

View File

@@ -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, 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, 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
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -400,15 +401,21 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "pres_partial") {
t.Fatalf("error should contain presentation ID, got: %s", errMsg)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %v", err)
}
if !strings.Contains(errMsg, "slide 2/2") {
t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg)
// The presentation was created but a slide add failed; the recovery hint
// carries the partial-progress context (which presentation exists, how many
// slides landed) so the caller can resume without recreating.
if !strings.Contains(p.Hint, "pres_partial") {
t.Fatalf("hint should contain presentation ID, got: %s", p.Hint)
}
if !strings.Contains(errMsg, "1 slide(s) added") {
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
if !strings.Contains(p.Hint, "slide 2/2") {
t.Fatalf("hint should indicate slide 2/2 failed, got: %s", p.Hint)
}
if !strings.Contains(p.Hint, "1 slide(s) added") {
t.Fatalf("hint should report 1 slide added before failure, got: %s", p.Hint)
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// slidesInputStatError maps a FileIO.Stat error for an input image path to a
// typed validation error, prefixing the caller's context message. Both path
// validation failures and other stat errors are user-actionable input problems
// (exit code 2). Already-typed errors are not expected here (Stat returns raw
// fs errors), so this always classifies as validation.
func slidesInputStatError(err error, msg string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: unsafe file path: %s", msg, err).WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
}
// appendSlidesProgressHint preserves err's typed classification (per
// ERROR_CONTRACT.md "propagate typed errors unchanged") and appends an
// orchestration-progress hint — e.g. "presentation was created; N image(s)
// uploaded before failure" — so a failure mid-sequence still tells the caller
// what partial state exists. An unclassified error (e.g. surfaced from a shared
// helper boundary before it can be classified) falls back to a typed internal
// error carrying the hint.
func appendSlidesProgressHint(err error, hint string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
if p.Hint != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"path/filepath"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -86,15 +86,15 @@ var SlidesMediaUpload = common.Shortcut{
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return slidesInputStatError(err, "file not found")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size()))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
filepath.Base(filePath), common.FormatSize(stat.Size())).WithParam("--file")
}
fileName := filepath.Base(filePath)
@@ -124,7 +124,7 @@ var SlidesMediaUpload = common.Shortcut{
// because the multipart upload API does not accept parent_type=slide_file.
func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, presentationID string) (string, error) {
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
fileName, common.FormatSize(fileSize))
}
parent := presentationID

View File

@@ -6,11 +6,10 @@ package slides
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -58,7 +57,7 @@ var SlidesReplaceSlide = common.Shortcut{
return err
}
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
return common.FlagErrorf("--slide-id cannot be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty")
}
parts, err := parseReplaceParts(runtime.Str("parts"))
if err != nil {
@@ -153,7 +152,7 @@ var SlidesReplaceSlide = common.Shortcut{
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
validate.EncodePathSegment(presentationID),
)
data, err := runtime.CallAPI("POST", url, query, body)
data, err := runtime.CallAPITyped("POST", url, query, body)
if err != nil {
return enrichSlidesReplaceError(err)
}
@@ -201,11 +200,11 @@ type replacePart struct {
func parseReplaceParts(raw string) ([]replacePart, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, common.FlagErrorf("--parts cannot be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts cannot be empty")
}
var decoded []map[string]interface{}
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil, common.FlagErrorf("--parts invalid JSON, must be an array of objects: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts invalid JSON, must be an array of objects: %v", err)
}
out := make([]replacePart, 0, len(decoded))
for i, m := range decoded {
@@ -213,35 +212,35 @@ func parseReplaceParts(raw string) ([]replacePart, error) {
if v, ok := m["action"]; ok {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--parts[%d].action must be a string", i)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action must be a string", i)
}
p.Action = s
}
if v, ok := m["replacement"]; ok {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--parts[%d].replacement must be a string", i)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement must be a string", i)
}
p.Replacement = &s
}
if v, ok := m["block_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--parts[%d].block_id must be a string", i)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].block_id must be a string", i)
}
p.BlockID = &s
}
if v, ok := m["insertion"]; ok {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--parts[%d].insertion must be a string", i)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion must be a string", i)
}
p.Insertion = &s
}
if v, ok := m["insert_before_block_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--parts[%d].insert_before_block_id must be a string", i)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insert_before_block_id must be a string", i)
}
p.InsertBeforeBlockID = &s
}
@@ -261,17 +260,18 @@ const slides3350001Hint = "common causes: (1) block_id not found in current slid
// enrichSlidesReplaceError attaches slides3350001Hint when the API returns
// 3350001 (invalid param). Other error codes pass through untouched.
func enrichSlidesReplaceError(err error) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != larkCodeSlidesInvalidParam {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != larkCodeSlidesInvalidParam {
return err
}
// Only fall back to the generic checklist when no upstream hint is
// already attached — don't clobber a more specific hint set by the
// backend or an earlier wrapper.
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = slides3350001Hint
// backend or an earlier wrapper. p points at the embedded Problem, so
// the mutation is reflected in the returned err.
if p.Hint == "" {
p.Hint = slides3350001Hint
}
return exitErr
return err
}
// validateReplaceParts enforces CLI-level invariants:
@@ -280,33 +280,33 @@ func enrichSlidesReplaceError(err error) error {
// - per-action required fields are present
func validateReplaceParts(parts []replacePart) error {
if len(parts) == 0 {
return common.FlagErrorf("--parts must contain at least 1 item")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item")
}
if len(parts) > maxReplaceParts {
return common.FlagErrorf("--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts)
}
for i, p := range parts {
switch p.Action {
case "block_replace":
if p.BlockID == nil || strings.TrimSpace(*p.BlockID) == "" {
return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty block_id", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty block_id", i)
}
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty replacement", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i)
}
case "block_insert":
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
return common.FlagErrorf("--parts[%d] (block_insert) requires non-empty insertion", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i)
}
case "str_replace":
// Backend still accepts str_replace, but product decision is to
// force structural edits through the CLI. Block it up-front so
// users don't build tooling around an option we won't keep.
return common.FlagErrorf("--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action)
case "":
return common.FlagErrorf("--parts[%d].action is required", i)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action is required", i)
default:
return common.FlagErrorf("--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action)
}
}
return nil
@@ -327,7 +327,7 @@ func injectBlockReplaceIDs(parts []replacePart) ([]map[string]interface{}, error
case "block_replace":
fixed, err := ensureXMLRootID(*p.Replacement, *p.BlockID)
if err != nil {
return nil, output.ErrValidation("--parts[%d].replacement: %v", i, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithCause(err)
}
fixed = ensureShapeHasContent(fixed)
m["block_id"] = *p.BlockID

View File

@@ -5,14 +5,13 @@ package slides
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
@@ -631,15 +630,15 @@ func TestReplaceSlide3350001ErrorEnrichment(t *testing.T) {
if err == nil {
t.Fatal("expected error for 3350001")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %v", err)
}
if exitErr.Detail.Code != 3350001 {
t.Fatalf("expected code 3350001, got %d", exitErr.Detail.Code)
if p.Code != 3350001 {
t.Fatalf("expected code 3350001, got %d", p.Code)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
if !strings.Contains(p.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
}
})
}
@@ -670,17 +669,17 @@ func TestReplaceSlideNon3350001ErrorNotEnriched(t *testing.T) {
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %v", err)
}
if exitErr.Detail.Code != 99991672 {
t.Fatalf("expected code 99991672, got %d", exitErr.Detail.Code)
if p.Code != 99991672 {
t.Fatalf("expected code 99991672, got %d", p.Code)
}
// Non-3350001 errors must not have the slides-specific hint attached.
// Assert the actual hint is not our 3350001 checklist, rather than a
// string the hint never emits.
if strings.Contains(exitErr.Detail.Hint, "common causes") {
t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", exitErr.Detail.Hint)
if strings.Contains(p.Hint, "common causes") {
t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", p.Hint)
}
}

View File

@@ -5,12 +5,11 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -95,7 +94,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string {
}
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
// translate from runtime.CallAPI responses or test fakes.
// translate from runtime.CallAPITyped responses or test fakes.
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
@@ -103,7 +102,7 @@ type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTas
// "simple_task_result" for delete-node).
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
if task == nil {
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, resultKey)
@@ -167,7 +166,7 @@ func pollWikiAsyncTask(
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
@@ -178,29 +177,18 @@ func pollWikiAsyncTask(
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
label, taskID, nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
// ErrWithHint rebuilds the error and drops the upstream Lark
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
// ExitError by hand so the original API code survives a fully
// failed poll, matching wrapWikiNodeDeleteAPIError.
return lastStatus, false, &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
// The poll error comes from a typed CallAPITyped path; append the resume
// hint in place so the original category / subtype / code / log_id
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
// errors unchanged"), matching wrapWikiNodeDeleteAPIError.
if p, ok := errs.ProblemOf(lastErr); ok {
if strings.TrimSpace(p.Hint) != "" {
hint = p.Hint + "\n" + hint
}
p.Hint = hint
return lastStatus, false, lastErr
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
}
return lastStatus, false, nil

View File

@@ -10,8 +10,8 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
@@ -98,16 +98,13 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
if ready {
t.Fatalf("ready = true, want false when every poll failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
}
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") ||
!strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") {
t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint)
}
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
@@ -118,15 +115,10 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
upstream := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "permission",
Code: 99991663,
Message: "permission denied",
Hint: "grant the wiki:node:retrieve scope",
},
}
// The upstream poll error is a typed error carrying its own hint, mirroring
// what runtime.CallAPITyped produces for a permission failure.
upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied").
WithHint("grant the wiki:node:retrieve scope")
_, _, err := pollWikiAsyncTask(
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
@@ -134,23 +126,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
},
"resume-cmd",
)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
}
// The upstream hint must lead so the actionable cause is read first, with
// the resume guidance appended. Type and exit code propagate from upstream.
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
// the resume guidance appended. The original typed error propagates in place.
if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") {
t.Fatalf("hint = %q, want upstream hint prepended", p.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "resume-cmd") {
t.Fatalf("hint = %q, want resume command appended", p.Hint)
}
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype)
}
if exitErr.Detail.Message != "permission denied" {
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
if p.Message != "permission denied" {
t.Fatalf("message = %q, want upstream message preserved", p.Message)
}
}

View File

@@ -9,8 +9,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct {
}
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
nil,
@@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (
}
func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_space"},
@@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec
func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error {
if spec.SpaceID == "" {
return output.ErrValidation("--space-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
}
return validateOptionalResourceName(spec.SpaceID, "--space-id")
}

View File

@@ -6,7 +6,6 @@ package wiki
import (
"bytes"
"context"
"errors"
"reflect"
"strings"
"sync"
@@ -14,11 +13,12 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -266,19 +266,18 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
// Seed an error that carries an upstream Lark Detail.Code so the test
// Seed a typed error that carries an upstream Lark code and hint so the test
// pins that structured fields survive a fully failed poll (not just the
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
// hint): the poll-exhaustion path must propagate the typed error in place.
seeded := errclass.BuildAPIError(
map[string]any{"code": float64(131006), "msg": "poll failed"},
errclass.ClassifyContext{},
)
if p, ok := errs.ProblemOf(seeded); ok {
p.Hint = "retry original"
}
client := &fakeWikiDeleteSpaceClient{
taskErrs: []error{&output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: 131006,
Message: "poll failed",
Hint: "retry original",
},
}},
taskErrs: []error{seeded},
}
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
@@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
}
if exitErr.Detail.Code != 131006 {
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
if p.Code != 131006 {
t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code)
}
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -65,7 +65,7 @@ var WikiMemberAdd = common.Shortcut{
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody())
data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody())
if err != nil {
return err
}
@@ -131,16 +131,16 @@ func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, e
return wikiMemberAddSpec{}, err
}
if spec.MemberID == "" {
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
}
// The space-member API rejects opendepartmentid grants under a
// tenant_access_token; surface that as a CLI validation error so callers do
// not waste a network round-trip on a server-side 403. The escape hatch is
// --as user, which is the only identity the API accepts for departments.
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
return wikiMemberAddSpec{}, output.ErrValidation(
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
)
).WithParam("--member-type")
}
// --member-type / --member-role enum membership is enforced by the
// framework's validateEnumFlags (runner.go) before Validate runs, so no

View File

@@ -6,7 +6,7 @@ package wiki
import (
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,10 +27,10 @@ var wikiMemberRoles = []string{"admin", "member"}
// tenant_access_token; same contract as +node-list / +node-create)
func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error {
if spaceID == "" {
return output.ErrValidation("--space-id is required and cannot be blank")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id")
}
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
}
return validateOptionalResourceName(spaceID, "--space-id")
}

View File

@@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -68,7 +68,7 @@ var WikiMemberRemove = common.Shortcut{
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(spec.MemberID),
)
data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody())
data, err := runtime.CallAPITyped("DELETE", path, nil, spec.RequestBody())
if err != nil {
return err
}
@@ -122,7 +122,7 @@ func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveS
return wikiMemberRemoveSpec{}, err
}
if spec.MemberID == "" {
return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
return wikiMemberRemoveSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
}
// Enum membership for --member-type / --member-role is enforced by the
// framework's validateEnumFlags (runner.go) before Validate runs.

View File

@@ -5,13 +5,12 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -65,7 +64,7 @@ var WikiMove = common.Shortcut{
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of letting the API return a confusing error.
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`").WithParam("--target-space-id")
}
return validateWikiMoveSpec(spec)
},
@@ -230,7 +229,7 @@ type wikiMoveAPI struct {
}
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
@@ -243,7 +242,7 @@ func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeReco
}
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
@@ -260,7 +259,7 @@ func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec
}
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
@@ -281,7 +280,7 @@ func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string,
}
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
@@ -324,28 +323,28 @@ func validateWikiMoveSpec(spec wikiMoveSpec) error {
if spec.NodeToken != "" {
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token cannot be combined with --obj-type, --obj-token, or --apply").WithParam("--node-token")
}
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-parent-token and --target-space-id cannot both be empty for wiki node move").WithParam("--target-space-id")
}
return nil
}
if spec.SourceSpaceID != "" {
return output.ErrValidation("--source-space-id can only be used with --node-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--source-space-id can only be used with --node-token").WithParam("--source-space-id")
}
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move").WithParam("--node-token")
}
if spec.ObjType == "" {
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for docs-to-wiki move").WithParam("--obj-type")
}
if spec.ObjToken == "" {
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-token is required for docs-to-wiki move").WithParam("--obj-token")
}
if spec.TargetSpaceID == "" {
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id is required for docs-to-wiki move").WithParam("--target-space-id")
}
return nil
@@ -426,7 +425,7 @@ func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.Run
case wikiMoveModeDocsToWiki:
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
default:
return nil, output.ErrValidation("unknown wiki move mode")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "unknown wiki move mode")
}
}
@@ -479,11 +478,11 @@ func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec
if targetSpaceID == "" {
targetSpaceID = parentSpaceID
} else if targetSpaceID != parentSpaceID {
return "", "", output.ErrValidation(
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--target-space-id %q does not match target parent node space %q",
spec.TargetSpaceID,
parentSpaceID,
)
).WithParam("--target-space-id")
}
}
@@ -549,7 +548,7 @@ func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *
}
return out, nil
default:
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
}
}
@@ -592,7 +591,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki move task failed: %s", status.PrimaryStatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
@@ -605,14 +604,18 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
taskID,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
// The poll error comes from a typed CallAPITyped path; append the resume
// hint in place so the original category / subtype / code / log_id
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
// errors unchanged").
if p, ok := errs.ProblemOf(lastErr); ok {
if strings.TrimSpace(p.Hint) != "" {
hint = p.Hint + "\n" + hint
}
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
p.Hint = hint
return lastStatus, false, lastErr
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
}
return lastStatus, false, nil
@@ -620,7 +623,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
if task == nil {
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return wikiMoveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
status := wikiMoveTaskStatus{

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
@@ -15,11 +14,11 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -837,7 +836,7 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
taskErrs: []error{errs.NewAPIError(errs.SubtypeServerError, "poll failed").WithHint("retry original")},
}
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
@@ -850,12 +849,12 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
}
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())

View File

@@ -9,7 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,10 +44,10 @@ var WikiNodeCopy = common.Shortcut{
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
if targetSpaceID == "" && targetParent == "" {
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --target-space-id or --target-parent-node-token is required").WithParam("--target-space-id")
}
if targetSpaceID != "" && targetParent != "" {
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id and --target-parent-node-token are mutually exclusive; provide only one").WithParam("--target-space-id")
}
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
return err
@@ -72,7 +72,7 @@ var WikiNodeCopy = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
common.MaskToken(nodeToken), common.MaskToken(spaceID))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken)),

View File

@@ -5,12 +5,12 @@ package wiki
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -170,7 +170,7 @@ type wikiNodeCreateAPI struct {
}
func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
@@ -183,7 +183,7 @@ func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNo
}
func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
nil,
@@ -196,7 +196,7 @@ func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wik
}
func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)),
nil,
@@ -231,22 +231,22 @@ func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity)
}
if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" {
return output.ErrValidation("--origin-node-token is required when --node-type=shortcut")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token is required when --node-type=shortcut").WithParam("--origin-node-token")
}
if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" {
return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token can only be used when --node-type=shortcut").WithParam("--origin-node-token")
}
// Bot identity has no meaningful "personal document library" target, so
// my_library must be rejected explicitly instead of deferring to API-time
// resolution errors.
if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token").WithParam("--space-id")
}
// Bot identity also cannot fall back implicitly, so it requires an explicit
// target or a parent it can resolve from.
if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" {
return output.ErrValidation("bot identity requires --space-id or --parent-node-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").WithParam("--space-id")
}
return nil
@@ -334,7 +334,7 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
return nil, wrapWikiNodeCreateRetryError(lastErr)
}
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node create returned no node")
}
return &wikiNodeCreateExecution{
@@ -346,45 +346,32 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
// isWikiNodeLockContention returns true if the error is a Lark API error with
// code 131009 (wiki node lock contention), which is retryable with backoff.
func isWikiNodeLockContention(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return exitErr.Detail.Code == output.LarkErrWikiLockContention
return p.Code == output.LarkErrWikiLockContention
}
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
// the original Lark error code survives in the envelope.
// API error in place, preserving its typed category / subtype / code / log_id.
func wrapWikiNodeCreateRetryError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
hint := fmt.Sprintf(
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
wikiNodeCreateMaxRetries,
)
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
if existing := strings.TrimSpace(p.Hint); existing != "" {
hint = existing + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
Err: exitErr.Err,
Raw: exitErr.Raw,
}
p.Hint = hint
return err
}
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
@@ -397,7 +384,7 @@ func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient
return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken)
}
if identity.IsBot() {
return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token")
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").WithParam("--space-id")
}
return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client)
}
@@ -434,12 +421,12 @@ func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wik
return wikiResolvedSpace{}, err
}
if parentSpaceID != resolved.SpaceID {
return wikiResolvedSpace{}, output.ErrValidation(
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id %q does not match parent node space %q (resolved space: %q)",
spec.SpaceID,
parentSpaceID,
resolved.SpaceID,
)
).WithParam("--space-id")
}
resolved.ParentNode = parent
@@ -483,21 +470,21 @@ func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) {
if node != nil && node.SpaceID != "" {
return node.SpaceID, nil
}
return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node lookup returned no space_id")
}
func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
if space != nil && space.SpaceID != "" {
return space.SpaceID, nil
}
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "personal document library was not found, please specify --space-id").WithParam("--space-id")
}
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
// the per-user real space_id. Shared by shortcuts that accept the my_library
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
nil, nil,
@@ -517,14 +504,14 @@ func validateOptionalResourceName(value, flagName string) error {
return nil
}
if err := validate.ResourceName(value, flagName); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName).WithCause(err)
}
return nil
}
func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response missing node")
}
return &wikiNodeRecord{
SpaceID: common.GetString(node, "space_id"),
@@ -542,7 +529,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) {
if space == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space response missing space")
}
return &wikiSpaceRecord{
SpaceID: common.GetString(space, "space_id"),

View File

@@ -16,13 +16,26 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiTestLockContentionErr builds the typed API error that runtime.CallAPITyped
// produces for a wiki write-lock-contention response (code 131009), so the
// retry path's errs.ProblemOf(...).Code check sees the same shape in tests as
// in production.
func wikiTestLockContentionErr() error {
return errclass.BuildAPIError(
map[string]any{"code": float64(output.LarkErrWikiLockContention), "msg": "lock contention"},
errclass.ClassifyContext{},
)
}
type fakeWikiNodeCreateCall struct {
SpaceID string
Spec wikiNodeCreateSpec
@@ -785,7 +798,7 @@ func TestWikiNodeURL(t *testing.T) {
func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -831,7 +844,7 @@ func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -853,25 +866,30 @@ func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
if len(client.createInvoked) != 3 {
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if exitErr.Detail.Code != output.LarkErrWikiLockContention {
t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention)
if p.Code != output.LarkErrWikiLockContention {
t.Fatalf("error code = %d, want %d", p.Code, output.LarkErrWikiLockContention)
}
if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") {
t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "failed after 2 retries") {
t.Fatalf("hint = %q, want retry exhaustion message", p.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "lock contention") {
t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "lock contention") {
t.Fatalf("hint = %q, want original classification hint preserved", p.Hint)
}
}
func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
t.Parallel()
otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention
// A typed API error for a different code (rate limit, not lock contention),
// mirroring what runtime.CallAPITyped produces.
otherErr := errclass.BuildAPIError(
map[string]any{"code": float64(output.LarkErrRateLimit), "msg": "rate limit"},
errclass.ClassifyContext{},
)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -901,7 +919,7 @@ func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -944,7 +962,7 @@ func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{

View File

@@ -5,14 +5,13 @@ package wiki
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -135,7 +134,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
if objType != "" && objType != "wiki" {
params["obj_type"] = objType
}
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
data, err := api.runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
if err != nil {
return nil, err
}
@@ -143,7 +142,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
}
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"DELETE",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
@@ -160,7 +159,7 @@ func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spe
}
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
@@ -188,7 +187,7 @@ func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec,
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
}
spec := wikiNodeDeleteSpec{
@@ -200,14 +199,14 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
).WithParam("--node-token")
}
spec.NodeToken = token
spec.SourceKind = "url"
@@ -222,32 +221,32 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
case spec.ObjType == "":
spec.ObjType = inferred
case spec.ObjType != inferred:
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, inferred,
)
).WithParam("--obj-type")
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
).WithParam("--node-token")
} else {
spec.NodeToken = tokenInput
spec.SourceKind = "raw"
}
if spec.ObjType == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required (one of: %s)",
strings.Join(wikiNodeDeleteObjTypes, ", "),
)
).WithParam("--obj-type")
}
if !isValidWikiDeleteObjType(spec.ObjType) {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type %q is not valid; pick one of: %s",
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
)
).WithParam("--obj-type")
}
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return wikiNodeDeleteSpec{}, err
@@ -405,12 +404,12 @@ func wrapWikiNodeDeleteAPIError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
var hint string
switch exitErr.Detail.Code {
switch p.Code {
case wikiDeleteNodeErrCodeApprovalRequired:
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
case wikiDeleteNodeErrCodeSubtreeTooLarge:
@@ -419,22 +418,11 @@ func wrapWikiNodeDeleteAPIError(err error) error {
if hint == "" {
return err
}
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
// Append the hint in place so the typed error keeps its category / subtype /
// code / log_id (per ERROR_CONTRACT.md "propagate typed errors unchanged").
if existing := strings.TrimSpace(p.Hint); existing != "" {
hint = existing + "\n" + hint
}
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
// the ExitError by hand so the Lark error code stays available to logs and
// downstream pivots.
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
}
p.Hint = hint
return err
}

View File

@@ -9,17 +9,17 @@ import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -357,63 +357,55 @@ func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeApprovalRequired,
Message: "node requires delete approval",
},
}
in := errclass.BuildAPIError(
map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T %v", got, got)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", p.Hint)
}
// Original code/message must be preserved so logs and dashboards still
// pivot on the upstream error code.
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
if p.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", p.Code)
}
if exitErr.Detail.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
if p.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", p.Message)
}
}
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
Message: "subtree too large",
},
}
in := errclass.BuildAPIError(
map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T %v", got, got)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", p.Hint)
}
}
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
}
in := errclass.BuildAPIError(
map[string]any{"code": float64(131005), "msg": "node not found"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
if !reflect.DeepEqual(got, in) {
t.Fatalf("unknown code should pass through; got %#v", got)
if got != in {
t.Fatalf("unknown code should pass through unchanged; got %#v", got)
}
}

View File

@@ -12,7 +12,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -98,7 +98,7 @@ var WikiNodeGet = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
if err != nil {
return err
}
@@ -109,10 +109,10 @@ var WikiNodeGet = common.Shortcut{
}
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id %q does not match the resolved node space %q (node_token=%s)",
spec.SpaceID, node.SpaceID, node.NodeToken,
)
).WithParam("--space-id")
}
if spec.SpaceID != "" && node.SpaceID == "" {
// The cross-check was requested but get_node returned no space_id,
@@ -178,8 +178,8 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
legacy := strings.TrimSpace(legacyToken)
switch {
case canonical != "" && legacy != "" && canonical != legacy:
return "", output.ErrValidation(
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)").WithParam("--node-token")
case canonical != "":
return nodeToken, nil
default:
@@ -193,7 +193,7 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required")
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
}
spec := wikiNodeGetSpec{
@@ -204,14 +204,14 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
).WithParam("--node-token")
}
spec.Token = token
if urlObjType == "" {
@@ -223,16 +223,16 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
case spec.ObjType == "" && urlObjType != "":
spec.ObjType = urlObjType
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, urlObjType,
)
).WithParam("--obj-type")
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
).WithParam("--node-token")
} else {
spec.Token = tokenInput
if looksLikeWikiNodeToken(spec.Token) {
@@ -241,10 +241,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
)
).WithParam("--obj-type")
}
} else {
spec.SourceKind = "raw-obj"
@@ -253,10 +253,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
)
).WithParam("--obj-type")
}
}
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -53,7 +54,7 @@ var WikiNodeList = common.Shortcut{
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
@@ -150,7 +151,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -60,14 +60,14 @@ var WikiSpaceCreate = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
data, err := runtime.CallAPITyped("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
if err != nil {
return err
}
raw := common.GetMap(data, "space")
if raw == nil {
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space create returned no space")
}
out := wikiSpaceCreateOutput(raw)
@@ -100,7 +100,7 @@ func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpe
Description: strings.TrimSpace(runtime.Str("description")),
}
if spec.Name == "" {
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
return wikiSpaceCreateSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required and cannot be blank").WithParam("--name")
}
return spec, nil
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -103,7 +104,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
data, err := runtime.CallAPITyped("GET", wikiSpacesAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}
@@ -181,10 +182,10 @@ func valueOrDash(v interface{}) string {
// +space-list and +node-list.
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and %d", maxPageSize).WithParam("--page-size")
}
if n := runtime.Int("page-limit"); n < 0 {
return common.FlagErrorf("--page-limit must be a non-negative integer")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
}
return nil
}