mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
Compare commits
8 Commits
feat/errs-
...
v1.0.50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/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)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/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/)
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
@@ -1066,6 +1088,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -301,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -329,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
@@ -352,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -165,7 +168,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -129,3 +130,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||
// Problem.Code rather than the legacy envelope shape.
|
||||
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||
WithCode(vcNoteDetailNotFoundCode)
|
||||
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||
}
|
||||
if isLarkCode(typedNotFound, 99999) {
|
||||
t.Error("a different expected code must not match")
|
||||
}
|
||||
|
||||
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||
t.Error("typed error with another code must not match")
|
||||
}
|
||||
|
||||
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||
t.Error("untyped error must not match")
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ package vc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,8 @@ const cleanupTimeout = 5 * time.Second
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -24,11 +25,15 @@ const cleanupTimeout = 5 * time.Second
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"param whiteboard_id is required for %s", eventType).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -58,6 +59,16 @@ func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("missing whiteboard_id should carry a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
@@ -70,6 +81,9 @@ func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -80,7 +83,8 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
@@ -90,7 +94,11 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +160,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +179,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
99
internal/event/consume/startup_guard_test.go
Normal file
99
internal/event/consume/startup_guard_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/validate_params_test.go
Normal file
64
internal/event/consume/validate_params_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
@@ -15,22 +15,21 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/event/",
|
||||
"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"
|
||||
|
||||
@@ -16,22 +16,22 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
//
|
||||
// Files that do not import shortcuts/common are skipped: the legacy helpers
|
||||
// are methods on common.RuntimeContext, so a same-named method on another
|
||||
// receiver (for example the event domain's APIClient interface, whose
|
||||
// implementation classifies into typed errs.* errors) is not a legacy call.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !importsPath(file, commonImportPath) {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importsPath reports whether the file imports the given package path.
|
||||
func importsPath(file *ast.File, importPath string) bool {
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(imp.Path.Value, "`\"") == importPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -813,6 +813,8 @@ func boom() error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -833,6 +835,8 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -853,6 +857,8 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -944,19 +950,12 @@ 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 {
|
||||
@@ -1005,14 +1004,7 @@ 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",
|
||||
}
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1023,13 +1015,9 @@ func boom() {
|
||||
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)
|
||||
}
|
||||
})
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,3 +1100,23 @@ func boom() error {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
||||
// The event domain's APIClient interface has a same-named CallAPI method
|
||||
// whose implementation classifies into typed errs.* errors; without the
|
||||
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
||||
// helper and must not fire.
|
||||
src := `package vc
|
||||
|
||||
import "github.com/larksuite/cli/internal/event"
|
||||
|
||||
func boom(rt event.APIClient) error {
|
||||
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.49",
|
||||
"version": "1.0.50",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -676,10 +676,30 @@ 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.
|
||||
// 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.
|
||||
// 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.
|
||||
func WrapSaveErrorTyped(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -239,7 +239,7 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg).WithCause(err)
|
||||
}
|
||||
b64 := strings.TrimSpace(string(out))
|
||||
data, decErr := base64.StdEncoding.DecodeString(b64)
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
package doc
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wrapDocNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving its subtype / code / log_id from the runtime boundary),
|
||||
@@ -14,3 +19,16 @@ func wrapDocNetworkErr(err error, format string, args ...any) error {
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// wrapDocInputFileErr wraps a --file Stat/read failure via the shared typed
|
||||
// helper (which sets the cause) and tags it with the --file param so agents
|
||||
// learn which flag to fix. The common helper is flag-agnostic, so the param is
|
||||
// attached here at the Doc call site rather than mutating shared behavior.
|
||||
func wrapDocInputFileErr(err error, readMsg string) error {
|
||||
wrapped := common.WrapInputStatErrorTyped(err, readMsg)
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(wrapped, &ve) {
|
||||
ve.Param = "--file"
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
420
shortcuts/doc/doc_errors_test.go
Normal file
420
shortcuts/doc/doc_errors_test.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testDocxToken is a bare docx token that parseDocumentRef accepts, letting the
|
||||
// validation tests reach the flag checks that run after --doc is resolved.
|
||||
const testDocxToken = "doxcnDocErrorsTestToken"
|
||||
|
||||
// docValidateRuntime builds a RuntimeContext carrying only the flags a Doc
|
||||
// Validate function reads. String values are applied (and marked Changed) only
|
||||
// when non-empty; int values are always applied so Changed() reports true,
|
||||
// mirroring how cobra records an explicitly supplied numeric flag.
|
||||
func docValidateRuntime(t *testing.T, str map[string]string, bools map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "docs"}
|
||||
fs := cmd.Flags()
|
||||
for name, val := range str {
|
||||
fs.String(name, "", "")
|
||||
if val != "" {
|
||||
if err := fs.Set(name, val); err != nil {
|
||||
t.Fatalf("set --%s=%q: %v", name, val, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, val := range bools {
|
||||
fs.Bool(name, false, "")
|
||||
if val {
|
||||
if err := fs.Set(name, "true"); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, val := range ints {
|
||||
fs.Int(name, 0, "")
|
||||
if err := fs.Set(name, strconv.Itoa(val)); err != nil {
|
||||
t.Fatalf("set --%s=%d: %v", name, val, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, nil)
|
||||
}
|
||||
|
||||
// assertValidationContract pins the typed envelope every migrated Doc
|
||||
// validation fault must emit: a *errs.ValidationError in CategoryValidation
|
||||
// with the expected Subtype, the single offending flag in Param, and every
|
||||
// involved flag in Params. Single-flag faults set Param and leave Params empty;
|
||||
// multi-flag faults (mutual exclusion, "one of A or B") leave Param empty and
|
||||
// enumerate each flag in Params so agents resolve them without parsing the text.
|
||||
func assertValidationContract(t *testing.T, err error, wantSubtype errs.Subtype, wantParam string, wantParams ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err)
|
||||
}
|
||||
if ve.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %q, want %q", ve.Category, errs.CategoryValidation)
|
||||
}
|
||||
if ve.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, wantSubtype)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
gotParams := make([]string, len(ve.Params))
|
||||
for i, p := range ve.Params {
|
||||
gotParams[i] = p.Name
|
||||
}
|
||||
if !slices.Equal(gotParams, wantParams) {
|
||||
t.Errorf("params = %v, want %v", gotParams, wantParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaInsertValidateContract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
str map[string]string
|
||||
bools map[string]bool
|
||||
ints map[string]int
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "neither file nor clipboard",
|
||||
str: map[string]string{"doc": testDocxToken},
|
||||
wantParam: "", // one-of-two flags: enumerated in Params
|
||||
wantParams: []string{"--file", "--from-clipboard"},
|
||||
},
|
||||
{
|
||||
name: "file and clipboard together",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png"},
|
||||
bools: map[string]bool{"from-clipboard": true},
|
||||
wantParam: "", // mutual exclusion: enumerated in Params
|
||||
wantParams: []string{"--file", "--from-clipboard"},
|
||||
},
|
||||
{
|
||||
name: "non-docx document",
|
||||
str: map[string]string{"doc": "https://example.larksuite.com/doc/xxxxxx", "file": "dummy.png"},
|
||||
wantParam: "--doc",
|
||||
},
|
||||
{
|
||||
name: "blank selection",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "selection-with-ellipsis": " "},
|
||||
wantParam: "--selection-with-ellipsis",
|
||||
},
|
||||
{
|
||||
name: "before without selection",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png"},
|
||||
bools: map[string]bool{"before": true},
|
||||
wantParam: "--before",
|
||||
},
|
||||
{
|
||||
name: "invalid file-view",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "bogus"},
|
||||
wantParam: "--file-view",
|
||||
},
|
||||
{
|
||||
name: "file-view without type file",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "card", "type": "image"},
|
||||
wantParam: "--file-view",
|
||||
},
|
||||
{
|
||||
name: "dimensions with non-image type",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "file"},
|
||||
ints: map[string]int{"width": 100},
|
||||
wantParam: "", // only --width was set here, so only it is enumerated
|
||||
wantParams: []string{"--width"},
|
||||
},
|
||||
{
|
||||
name: "non-positive width",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
||||
ints: map[string]int{"width": 0},
|
||||
wantParam: "--width",
|
||||
},
|
||||
{
|
||||
name: "non-positive height",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
||||
ints: map[string]int{"height": 0},
|
||||
wantParam: "--height",
|
||||
},
|
||||
{
|
||||
name: "width over maximum",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
||||
ints: map[string]int{"width": 10001},
|
||||
wantParam: "--width",
|
||||
},
|
||||
{
|
||||
name: "height over maximum",
|
||||
str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"},
|
||||
ints: map[string]int{"height": 10001},
|
||||
wantParam: "--height",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := docValidateRuntime(t, tc.str, tc.bools, tc.ints)
|
||||
err := DocMediaInsert.Validate(context.Background(), rt)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateV2Contract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
str map[string]string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "content required",
|
||||
str: map[string]string{},
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "parent token and position mutually exclusive",
|
||||
str: map[string]string{"content": "<doc/>", "parent-token": "fldcnX", "parent-position": "my_library"},
|
||||
wantParam: "", // mutual exclusion: enumerated in Params
|
||||
wantParams: []string{"--parent-token", "--parent-position"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := docValidateRuntime(t, tc.str, nil, nil)
|
||||
err := validateCreateV2(context.Background(), rt)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2Contract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
str map[string]string
|
||||
ints map[string]int
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "range mode without block ids",
|
||||
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "range"},
|
||||
wantParam: "", // either --start-block-id or --end-block-id: enumerated in Params
|
||||
wantParams: []string{"--start-block-id", "--end-block-id"},
|
||||
},
|
||||
{
|
||||
name: "keyword mode without keyword",
|
||||
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "keyword"},
|
||||
wantParam: "--keyword",
|
||||
},
|
||||
{
|
||||
name: "section mode without start block id",
|
||||
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "section"},
|
||||
wantParam: "--start-block-id",
|
||||
},
|
||||
{
|
||||
name: "negative context-before",
|
||||
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "outline"},
|
||||
ints: map[string]int{"context-before": -1},
|
||||
wantParam: "--context-before",
|
||||
},
|
||||
{
|
||||
name: "unknown scope",
|
||||
str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "bogus"},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := docValidateRuntime(t, tc.str, nil, tc.ints)
|
||||
err := validateFetchV2(context.Background(), rt)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDocsSearchRequestPreservesParseCause pins the --filter parse faults:
|
||||
// the typed envelope carries Param --filter and chains the original parse error
|
||||
// so errors.Is/Unwrap traversal keeps the underlying JSON/time-parse detail.
|
||||
func TestBuildDocsSearchRequestPreservesParseCause(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
filter string
|
||||
}{
|
||||
{"invalid filter json", "{not json"},
|
||||
{"invalid open_time start", `{"open_time":{"start":"not-a-time"}}`},
|
||||
{"invalid open_time end", `{"open_time":{"end":"not-a-time"}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := buildDocsSearchRequest("q", tc.filter, "", "15")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--filter" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--filter")
|
||||
}
|
||||
if errors.Unwrap(ve) == nil {
|
||||
t.Error("parse error not chained: errors.Unwrap == nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDocNetworkErr pins wrapDocNetworkErr's contract: a typed error passes
|
||||
// through untouched, while a raw error becomes a transport-level NetworkError
|
||||
// that still chains the original cause for errors.Is/Unwrap.
|
||||
func TestWrapDocNetworkErr(t *testing.T) {
|
||||
t.Run("typed error passes through unchanged", func(t *testing.T) {
|
||||
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")
|
||||
got := wrapDocNetworkErr(typed, "fetch failed")
|
||||
if got != error(typed) {
|
||||
t.Fatalf("typed error must pass through unchanged, got %T", got)
|
||||
}
|
||||
})
|
||||
t.Run("raw error becomes transport network error", func(t *testing.T) {
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapDocNetworkErr(raw, "fetch failed: %s", "docx")
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("raw error must become *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Error("cause not chained: errors.Is(got, raw) == false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWrapDocInputFileErr pins that a --file stat/read failure becomes a typed
|
||||
// validation error tagged with the --file param and the cause preserved, so an
|
||||
// agent knows which flag to fix even though the shared helper is flag-agnostic.
|
||||
func TestWrapDocInputFileErr(t *testing.T) {
|
||||
raw := errors.New("no such file or directory")
|
||||
got := wrapDocInputFileErr(raw, "file not found")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(got, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError (%v)", got, got)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--file")
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Error("cause not chained: errors.Is(got, raw) == false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateV2Contract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
str map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "command required",
|
||||
str: map[string]string{"doc": testDocxToken},
|
||||
wantParam: "--command",
|
||||
},
|
||||
{
|
||||
name: "invalid command",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "bogus"},
|
||||
wantParam: "--command",
|
||||
},
|
||||
{
|
||||
name: "str_replace without pattern",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "block_delete without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without content",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after", "block-id": "blkX"},
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "block_copy_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_copy_insert_after without src block ids",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after", "block-id": "blkX"},
|
||||
wantParam: "--src-block-ids",
|
||||
},
|
||||
{
|
||||
name: "block_move_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_move_after"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_move_after without src block ids",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX"},
|
||||
wantParam: "--src-block-ids",
|
||||
},
|
||||
{
|
||||
name: "block_move_after rejects content",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX", "src-block-ids": "blkY", "content": "x"},
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "block_replace without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_replace"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_replace without content",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_replace", "block-id": "blkX"},
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "overwrite without content",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "overwrite"},
|
||||
wantParam: "--content",
|
||||
},
|
||||
{
|
||||
name: "append without content",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "append"},
|
||||
wantParam: "--content",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := docValidateRuntime(t, tc.str, nil, nil)
|
||||
err := validateUpdateV2(context.Background(), rt)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,16 @@ var DocMediaInsert = common.Shortcut{
|
||||
filePath := runtime.Str("file")
|
||||
fromClipboard := runtime.Bool("from-clipboard")
|
||||
if filePath == "" && !fromClipboard {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required").WithParams(
|
||||
errs.InvalidParam{Name: "--file", Reason: "provide either --file or --from-clipboard"},
|
||||
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide either --file or --from-clipboard"},
|
||||
)
|
||||
}
|
||||
if filePath != "" && fromClipboard {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive").WithParams(
|
||||
errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --from-clipboard"},
|
||||
errs.InvalidParam{Name: "--from-clipboard", Reason: "mutually exclusive with --file"},
|
||||
)
|
||||
}
|
||||
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -103,7 +109,14 @@ var DocMediaInsert = common.Shortcut{
|
||||
widthChanged := runtime.Changed("width")
|
||||
heightChanged := runtime.Changed("height")
|
||||
if (widthChanged || heightChanged) && runtime.Str("type") != "image" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image")
|
||||
var params []errs.InvalidParam
|
||||
if widthChanged {
|
||||
params = append(params, errs.InvalidParam{Name: "--width", Reason: "only applies when --type=image"})
|
||||
}
|
||||
if heightChanged {
|
||||
params = append(params, errs.InvalidParam{Name: "--height", Reason: "only applies when --type=image"})
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image").WithParams(params...)
|
||||
}
|
||||
if widthChanged && runtime.Int("width") <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width")
|
||||
@@ -269,7 +282,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
} else {
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatErrorTyped(err, "file not found")
|
||||
return wrapDocInputFileErr(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
@@ -380,14 +393,20 @@ var DocMediaInsert = common.Shortcut{
|
||||
f, openErr := runtime.FileIO().Open(filePath)
|
||||
if openErr != nil {
|
||||
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(openErr).WithParams(
|
||||
errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"},
|
||||
errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"},
|
||||
))
|
||||
}
|
||||
nativeW, nativeH, dimErr = detectImageDimensions(f)
|
||||
f.Close()
|
||||
}
|
||||
if dimErr != nil {
|
||||
return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName))
|
||||
"unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(dimErr).WithParams(
|
||||
errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"},
|
||||
errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"},
|
||||
))
|
||||
}
|
||||
dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH)
|
||||
finalWidth = dims.width
|
||||
|
||||
@@ -84,7 +84,7 @@ var DocMediaUpload = common.Shortcut{
|
||||
// Validate file
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatErrorTyped(err, "file not found")
|
||||
return wrapDocInputFileErr(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
|
||||
@@ -29,7 +29,10 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
errs.InvalidParam{Name: "--parent-token", Reason: "mutually exclusive with --parent-position"},
|
||||
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -182,7 +182,10 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
case "range":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||||
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams(
|
||||
errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},
|
||||
errs.InvalidParam{Name: "--end-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},
|
||||
)
|
||||
}
|
||||
return nil
|
||||
case "keyword":
|
||||
|
||||
@@ -160,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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter").WithCause(err)
|
||||
}
|
||||
if err := convertTimeRangeInFilter(filter, "open_time"); err != nil {
|
||||
return nil, err
|
||||
@@ -226,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter").WithCause(err)
|
||||
}
|
||||
result["start"] = startTime
|
||||
}
|
||||
if end, ok := rangeMap["end"].(string); ok && end != "" {
|
||||
endTime, err := toUnixSeconds(end)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter").WithCause(err)
|
||||
}
|
||||
result["end"] = endTime
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ var validCommandsV2 = map[string]bool{
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ var DriveAddComment = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
|
||||
|
||||
32
shortcuts/event/errors.go
Normal file
32
shortcuts/event/errors.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
func eventValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func eventValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return eventValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
// eventValidationParamErrorWithCause appends ": <err>" to the formatted
|
||||
// message and preserves err as the unwrap cause.
|
||||
func eventValidationParamErrorWithCause(err error, param, format string, args ...any) *errs.ValidationError {
|
||||
return eventValidationParamError(param, format+": %s", append(args, err)...).WithCause(err)
|
||||
}
|
||||
|
||||
// eventFileIOError appends ": <err>" to the formatted message and preserves
|
||||
// err as the unwrap cause.
|
||||
func eventFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format+": %s", append(args, err)...).WithCause(err)
|
||||
}
|
||||
|
||||
// eventNetworkError appends ": <err>" to the formatted message and preserves
|
||||
// err as the unwrap cause.
|
||||
func eventNetworkError(err error, format string, args ...any) *errs.NetworkError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format+": %s", append(args, err)...).WithCause(err)
|
||||
}
|
||||
@@ -63,13 +63,13 @@ func NewEventPipeline(
|
||||
func (p *EventPipeline) EnsureDirs() error {
|
||||
if p.config.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(p.config.OutputDir, 0700); err != nil {
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
return eventFileIOError(err, "create output dir")
|
||||
}
|
||||
}
|
||||
if p.config.Router != nil {
|
||||
for _, route := range p.config.Router.routes {
|
||||
if err := vfs.MkdirAll(route.dir, 0700); err != nil {
|
||||
return fmt.Errorf("create route dir %s: %w", route.dir, err)
|
||||
return eventFileIOError(err, "create route dir %s", route.dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,7 +16,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// chdirTemp changes cwd to a fresh temp dir for the test duration.
|
||||
@@ -44,6 +51,87 @@ func makeRawEvent(eventType string, eventJSON string) *RawEvent {
|
||||
}
|
||||
}
|
||||
|
||||
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, param string) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(%T) = false, error: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
if param != "" {
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error %T is not *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventTypedErrorHelpers(t *testing.T) {
|
||||
cause := errors.New("cause")
|
||||
|
||||
validation := eventValidationError("bad input")
|
||||
requireProblem(t, validation, errs.CategoryValidation, errs.SubtypeInvalidArgument, "")
|
||||
|
||||
paramErr := eventValidationParamErrorWithCause(cause, "--flag", "bad %s value", "flag")
|
||||
requireProblem(t, paramErr, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--flag")
|
||||
if got := paramErr.Error(); got != "bad flag value: cause" {
|
||||
t.Fatalf("message = %q, want %q", got, "bad flag value: cause")
|
||||
}
|
||||
if !errors.Is(paramErr, cause) {
|
||||
t.Fatal("validation error should preserve its cause")
|
||||
}
|
||||
|
||||
fileErr := eventFileIOError(cause, "write failed")
|
||||
requireProblem(t, fileErr, errs.CategoryInternal, errs.SubtypeFileIO, "")
|
||||
if got := fileErr.Error(); got != "write failed: cause" {
|
||||
t.Fatalf("message = %q, want %q", got, "write failed: cause")
|
||||
}
|
||||
if !errors.Is(fileErr, cause) {
|
||||
t.Fatal("file_io error should preserve its cause")
|
||||
}
|
||||
|
||||
networkErr := eventNetworkError(cause, "websocket failed")
|
||||
requireProblem(t, networkErr, errs.CategoryNetwork, errs.SubtypeNetworkTransport, "")
|
||||
if got := networkErr.Error(); got != "websocket failed: cause" {
|
||||
t.Fatalf("message = %q, want %q", got, "websocket failed: cause")
|
||||
}
|
||||
if !errors.Is(networkErr, cause) {
|
||||
t.Fatal("network error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func newSubscribeTestRuntime(t *testing.T) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "+subscribe"}
|
||||
cmd.Flags().String("event-types", "", "")
|
||||
cmd.Flags().String("filter", "", "")
|
||||
cmd.Flags().Bool("json", false, "")
|
||||
cmd.Flags().Bool("compact", false, "")
|
||||
cmd.Flags().String("output-dir", "", "")
|
||||
cmd.Flags().Bool("quiet", false, "")
|
||||
cmd.Flags().StringArray("route", nil, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
|
||||
return &common.RuntimeContext{
|
||||
Cmd: cmd,
|
||||
Config: &core.CliConfig{
|
||||
AppID: "cli_event_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: cmdutil.NewIOStreams(strings.NewReader(""), &out, &errOut),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Registry ---
|
||||
|
||||
func TestRegistryLookup(t *testing.T) {
|
||||
@@ -63,9 +151,11 @@ func TestRegistryDuplicateReturnsError(t *testing.T) {
|
||||
if err := r.Register(&ImMessageProcessor{}); err != nil {
|
||||
t.Fatalf("first register should succeed: %v", err)
|
||||
}
|
||||
if err := r.Register(&ImMessageProcessor{}); err == nil {
|
||||
err := r.Register(&ImMessageProcessor{})
|
||||
if err == nil {
|
||||
t.Error("expected error on duplicate registration")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeUnknown, "")
|
||||
}
|
||||
|
||||
// --- Filters ---
|
||||
@@ -106,6 +196,54 @@ func TestRegexFilter_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsUnsafeOutputDir(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
if err := rt.Cmd.Flags().Set("output-dir", "/tmp/events"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EventSubscribe.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output-dir error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--output-dir")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("unsafe output-dir error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsInvalidFilter(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.Cmd.Flags().Set("filter", "[invalid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EventSubscribe.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid filter error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--filter")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("invalid filter error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsInvalidRoute(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
if err := rt.Cmd.Flags().Set("force", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.Cmd.Flags().Set("route", "no-equals-sign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := EventSubscribe.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid route error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestFilterChain(t *testing.T) {
|
||||
etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1")
|
||||
rf, _ := NewRegexFilter("im\\..*")
|
||||
@@ -339,6 +477,106 @@ func TestPipeline_OutputDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeExecuteRejectsHeldLock(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
lock, err := lockfile.ForSubscribe("cli_event_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lock.TryLock(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = lock.Unlock() })
|
||||
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
execErr := EventSubscribe.Execute(context.Background(), rt)
|
||||
if execErr == nil {
|
||||
t.Fatal("expected lock-held error")
|
||||
}
|
||||
requireProblem(t, execErr, errs.CategoryValidation, errs.SubtypeFailedPrecondition, "")
|
||||
if !errors.Is(execErr, lockfile.ErrHeld) {
|
||||
t.Error("lock-held error should preserve lockfile.ErrHeld for errors.Is")
|
||||
}
|
||||
p, _ := errs.ProblemOf(execErr)
|
||||
if p.Hint == "" {
|
||||
t.Error("lock-held error should carry a recovery hint")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(execErr, &ve) && ve.Param != "" {
|
||||
t.Errorf("lock contention names no offending flag; param = %q, want empty", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSubscribeDryRunEchoesFlags(t *testing.T) {
|
||||
rt := newSubscribeTestRuntime(t)
|
||||
for flag, value := range map[string]string{
|
||||
"event-types": "im.message.receive_v1",
|
||||
"filter": "^im\\.",
|
||||
"output-dir": "events_out",
|
||||
} {
|
||||
if err := rt.Cmd.Flags().Set(flag, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := rt.Cmd.Flags().Set("route", "^im\\.message=dir:./messages"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d := EventSubscribe.DryRun(context.Background(), rt)
|
||||
if d == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
payload, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"command":"event +subscribe"`,
|
||||
`"app_id":"cli_event_test"`,
|
||||
`"event_types":"im.message.receive_v1"`,
|
||||
`"output_dir":"events_out"`,
|
||||
} {
|
||||
if !strings.Contains(string(payload), want) {
|
||||
t.Errorf("dry-run payload missing %s\ngot: %s", want, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_EnsureDirsRouteDirFileIOError(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("blocked", []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := ParseRoutes([]string{`^im\.=dir:./blocked/child`})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRoutes: %v", err)
|
||||
}
|
||||
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
|
||||
PipelineConfig{Mode: TransformCompact, Router: router}, io.Discard, io.Discard)
|
||||
err = p.EnsureDirs()
|
||||
if err == nil {
|
||||
t.Fatal("expected file_io error for route dir blocked by a file")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
|
||||
}
|
||||
|
||||
func TestPipeline_EnsureDirsFileIOError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "not-a-dir")
|
||||
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := NewEventPipeline(DefaultRegistry(), NewFilterChain(),
|
||||
PipelineConfig{Mode: TransformCompact, OutputDir: filepath.Join(path, "child")}, io.Discard, io.Discard)
|
||||
err := p.EnsureDirs()
|
||||
if err == nil {
|
||||
t.Fatal("expected file_io error")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeFileIO, "")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("file_io error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pipeline: JsonFlag ---
|
||||
|
||||
func TestPipeline_JsonFlag(t *testing.T) {
|
||||
@@ -608,6 +846,7 @@ func TestParseRoutes_MissingEquals(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for missing =")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_InvalidRegex(t *testing.T) {
|
||||
@@ -615,6 +854,10 @@ func TestParseRoutes_InvalidRegex(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid regex")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Fatal("invalid regex error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoutes_MissingPrefix(t *testing.T) {
|
||||
@@ -622,6 +865,7 @@ func TestParseRoutes_MissingPrefix(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for missing dir: prefix")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
if !strings.Contains(err.Error(), "dir:") {
|
||||
t.Errorf("error should mention dir: prefix, got: %v", err)
|
||||
}
|
||||
@@ -632,6 +876,7 @@ func TestParseRoutes_EmptyPath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
|
||||
@@ -639,6 +884,7 @@ func TestParseRoutes_RejectsAbsolutePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path in route")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_RejectsTraversal(t *testing.T) {
|
||||
@@ -646,6 +892,7 @@ func TestParseRoutes_RejectsTraversal(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal in route")
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, "--route")
|
||||
}
|
||||
|
||||
func TestParseRoutes_PathSafety(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package event
|
||||
|
||||
import "fmt"
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// ProcessorRegistry manages event_type → EventProcessor mappings.
|
||||
type ProcessorRegistry struct {
|
||||
@@ -23,7 +23,7 @@ func NewProcessorRegistry(fallback EventProcessor) *ProcessorRegistry {
|
||||
func (r *ProcessorRegistry) Register(p EventProcessor) error {
|
||||
et := p.EventType()
|
||||
if _, exists := r.processors[et]; exists {
|
||||
return fmt.Errorf("duplicate event processor for: %s", et)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "duplicate event processor for: %s", et)
|
||||
}
|
||||
r.processors[et] = p
|
||||
return nil
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -34,27 +33,27 @@ func ParseRoutes(specs []string) (*EventRouter, error) {
|
||||
for _, spec := range specs {
|
||||
parts := strings.SplitN(spec, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec)
|
||||
return nil, eventValidationParamError("--route", "invalid --route %q: expected format regex=dir:./path", spec)
|
||||
}
|
||||
pattern := parts[0]
|
||||
target := parts[1]
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid regex in route %q: %w", spec, err)
|
||||
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid regex in --route %q", spec)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(target, "dir:") {
|
||||
return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
|
||||
return nil, eventValidationParamError("--route", "invalid --route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target)
|
||||
}
|
||||
dir := strings.TrimPrefix(target, "dir:")
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("invalid route %q: directory path is empty", spec)
|
||||
return nil, eventValidationParamError("--route", "invalid --route %q: directory path is empty", spec)
|
||||
}
|
||||
|
||||
safeDir, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid route %q: %w", spec, err)
|
||||
return nil, eventValidationParamErrorWithCause(err, "--route", "invalid --route %q", spec)
|
||||
}
|
||||
|
||||
routes = append(routes, Route{pattern: re, dir: safeDir})
|
||||
|
||||
@@ -6,6 +6,7 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -144,7 +146,7 @@ var EventSubscribe = common.Shortcut{
|
||||
if outputDir != "" {
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return eventValidationParamErrorWithCause(err, "--output-dir", "unsafe --output-dir")
|
||||
}
|
||||
outputDir = safePath
|
||||
}
|
||||
@@ -162,15 +164,18 @@ var EventSubscribe = common.Shortcut{
|
||||
if !forceFlag {
|
||||
lock, err := lockfile.ForSubscribe(runtime.Config.AppID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create lock: %w", err)
|
||||
return eventFileIOError(err, "failed to create event subscriber lock")
|
||||
}
|
||||
if err := lock.TryLock(); err != nil {
|
||||
return output.ErrValidation(
|
||||
"another event +subscribe instance is already running for app %s\n"+
|
||||
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
|
||||
" Use --force to bypass this check.",
|
||||
runtime.Config.AppID,
|
||||
)
|
||||
if errors.Is(err, lockfile.ErrHeld) {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event +subscribe instance is already running for app %s\n"+
|
||||
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
|
||||
" Use --force to bypass this check.",
|
||||
runtime.Config.AppID,
|
||||
).WithHint("stop the existing subscriber for this app, or rerun with --force if you accept split event delivery").WithCause(err)
|
||||
}
|
||||
return eventFileIOError(err, "failed to acquire event subscriber lock")
|
||||
}
|
||||
defer lock.Unlock()
|
||||
}
|
||||
@@ -179,7 +184,7 @@ var EventSubscribe = common.Shortcut{
|
||||
eventTypeFilter := NewEventTypeFilter(eventTypesStr)
|
||||
regexFilter, err := NewRegexFilter(filterStr)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid --filter regex: %s", filterStr)
|
||||
return eventValidationParamErrorWithCause(err, "--filter", "invalid --filter regex %q", filterStr)
|
||||
}
|
||||
var filterList []EventFilter
|
||||
if eventTypeFilter != nil {
|
||||
@@ -193,7 +198,7 @@ var EventSubscribe = common.Shortcut{
|
||||
// --- Parse route ---
|
||||
router, err := ParseRoutes(routeSpecs)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid --route: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Build pipeline ---
|
||||
@@ -292,7 +297,7 @@ var EventSubscribe = common.Shortcut{
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return output.ErrNetwork("WebSocket connection failed: %v", err)
|
||||
return eventNetworkError(err, "WebSocket connection failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package markdown
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -18,6 +19,7 @@ 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"
|
||||
)
|
||||
@@ -83,18 +85,16 @@ func (spec markdownUploadSpec) Target() markdownUploadTarget {
|
||||
func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error {
|
||||
switch {
|
||||
case spec.ContentSet && spec.FileSet:
|
||||
return markdownValidationError("--content and --file are mutually exclusive").
|
||||
WithParams(markdownInvalidParam("--content", "mutually exclusive"), markdownInvalidParam("--file", "mutually exclusive"))
|
||||
return common.FlagErrorf("--content and --file are mutually exclusive")
|
||||
case !spec.ContentSet && !spec.FileSet:
|
||||
return markdownValidationError("specify exactly one of --content or --file").
|
||||
WithParams(markdownInvalidParam("--content", "required; specify exactly one"), markdownInvalidParam("--file", "required; specify exactly one"))
|
||||
return common.FlagErrorf("specify exactly one of --content or --file")
|
||||
}
|
||||
|
||||
if markdownFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
return markdownValidationParamError("--folder-token", "--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder")
|
||||
}
|
||||
if markdownFlagExplicitlyEmpty(runtime, "wiki-token") {
|
||||
return markdownValidationParamError("--wiki-token", "--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
return common.FlagErrorf("--wiki-token cannot be empty; provide a valid wiki node token or omit the flag entirely")
|
||||
}
|
||||
targets := 0
|
||||
if spec.FolderToken != "" {
|
||||
@@ -104,23 +104,22 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
targets++
|
||||
}
|
||||
if targets > 1 {
|
||||
return markdownValidationError("--folder-token and --wiki-token are mutually exclusive").
|
||||
WithParams(markdownInvalidParam("--folder-token", "mutually exclusive"), markdownInvalidParam("--wiki-token", "mutually exclusive"))
|
||||
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return markdownValidationParamError("--folder-token", "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
|
||||
return markdownValidationParamError("--wiki-token", "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if requireName && spec.ContentSet {
|
||||
if strings.TrimSpace(spec.FileName) == "" {
|
||||
return markdownValidationParamError("--name", "--name is required when using --content")
|
||||
return common.FlagErrorf("--name is required when using --content")
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil {
|
||||
return err
|
||||
@@ -129,10 +128,10 @@ func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpe
|
||||
|
||||
if spec.FileSet {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return markdownValidationParamError("--file", "--file cannot be empty")
|
||||
return common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
if _, err := validate.SafeInputPath(spec.FilePath); err != nil {
|
||||
return markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil {
|
||||
return err
|
||||
@@ -155,10 +154,10 @@ func markdownFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName string
|
||||
func validateMarkdownFileName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
return common.FlagErrorf("%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(trimmed), ".md") {
|
||||
return markdownValidationParamError(flagName, "%s must end with .md", flagName)
|
||||
return common.FlagErrorf("%s must end with .md", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -202,9 +201,22 @@ 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 markdownValidationError("%s", markdownEmptyContentError)
|
||||
return output.ErrValidation("%s", markdownEmptyContentError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -215,12 +227,12 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
size = int64(len(spec.Content))
|
||||
} else {
|
||||
if strings.TrimSpace(spec.FilePath) == "" {
|
||||
return 0, markdownValidationParamError("--file", "--file cannot be empty")
|
||||
return 0, common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return 0, common.WrapInputStatErrorTyped(err)
|
||||
return 0, common.WrapInputStatError(err)
|
||||
}
|
||||
size = info.Size()
|
||||
}
|
||||
@@ -551,7 +563,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
|
||||
n, readErr := io.ReadFull(fileReader, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return markdownValidationError("cannot read file: %s", readErr).WithCause(readErr)
|
||||
return output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
|
||||
@@ -5,6 +5,7 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
@@ -13,7 +14,6 @@ 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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", 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 markdownValidationParamError("--file", "unsafe file path: %s", err).WithCause(err)
|
||||
return output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if spec.ContextLines < 0 {
|
||||
return markdownValidationParamError("--context-lines", "--context-lines must be >= 0")
|
||||
return output.ErrValidation("--context-lines must be >= 0")
|
||||
}
|
||||
if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" {
|
||||
return markdownValidationParamError("--format", "markdown +diff only supports --format json or pretty")
|
||||
return output.ErrValidation("markdown +diff only supports --format json or pretty")
|
||||
}
|
||||
if spec.FilePath == "" {
|
||||
if spec.FromVersion == "" && spec.ToVersion == "" {
|
||||
return markdownValidationError("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff")
|
||||
}
|
||||
if spec.FromVersion == "" && spec.ToVersion != "" {
|
||||
return markdownValidationParamError("--to-version", "--to-version requires --from-version")
|
||||
return common.FlagErrorf("--to-version requires --from-version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if spec.ToVersion != "" {
|
||||
return markdownValidationParamError("--to-version", "--to-version is not supported together with --file")
|
||||
return common.FlagErrorf("--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 markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
return output.ErrValidation("%s cannot be empty", flagName)
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
|
||||
return output.ErrValidation("%s must be a numeric version string", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -178,16 +178,17 @@ 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.WrapInputStatErrorTyped(err)
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
payload, err := readMarkdownDiffPayload(f, "local Markdown file")
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return "", markdownValidationError("cannot read file: %s", err).WithCause(err)
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
@@ -198,7 +199,7 @@ func readMarkdownDiffPayload(r io.Reader, source string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) > markdownDiffMaxContentBytes {
|
||||
return nil, markdownValidationError("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
return nil, output.ErrValidation("%s exceeds %s markdown +diff content limit", source, common.FormatSize(markdownDiffMaxContentBytes))
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -215,18 +214,18 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkdownDownloadErrorPreservesStructuredErrors(t *testing.T) {
|
||||
apiErr := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(99991663)
|
||||
apiErr := output.ErrAPI(99991663, "permission denied", map[string]interface{}{"permission": "drive:file:download"})
|
||||
if got := wrapMarkdownDownloadError(apiErr); got != apiErr {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %v, want original API error", got)
|
||||
}
|
||||
|
||||
got := wrapMarkdownDownloadError(errors.New("dial tcp timeout"))
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want typed problem", got)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("wrapMarkdownDownloadError() = %T, want *output.ExitError", got)
|
||||
}
|
||||
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 exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "download failed: dial tcp timeout") {
|
||||
t.Fatalf("wrapped error = %q", got.Error())
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func markdownValidationError(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func markdownValidationParamError(param, format string, args ...any) *errs.ValidationError {
|
||||
return markdownValidationError(format, args...).WithParam(param)
|
||||
}
|
||||
|
||||
func markdownInvalidParam(name, reason string) errs.InvalidParam {
|
||||
return errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
|
||||
func markdownNetworkError(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
func wrapMarkdownDownloadError(err error) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Category == errs.CategoryValidation {
|
||||
return err
|
||||
}
|
||||
return markdownPrefixProblem(err, "download failed")
|
||||
}
|
||||
return markdownNetworkError(err, "download failed: %s", err)
|
||||
}
|
||||
|
||||
func markdownPrefixProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(action) != "" {
|
||||
p.Message = action + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
)
|
||||
@@ -34,14 +35,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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outputPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(outputPath); err != nil {
|
||||
return markdownValidationParamError("--output", "unsafe output path: %s", err).WithCause(err)
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -66,7 +67,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return wrapMarkdownDownloadError(err)
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -74,7 +75,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
if outputPath == "" {
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return wrapMarkdownDownloadError(err)
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
@@ -92,7 +93,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
outputPath = filepath.Join(outputPath, fileName)
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("overwrite") {
|
||||
return markdownValidationParamError("--output", "output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
@@ -100,7 +101,7 @@ var MarkdownFetch = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -29,7 +30,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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileToken: fileToken,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -48,7 +49,7 @@ var MarkdownPatch = common.Shortcut{
|
||||
}
|
||||
if spec.Regex {
|
||||
if _, err := regexp.Compile(spec.Pattern); err != nil {
|
||||
return markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
|
||||
return output.ErrValidation("invalid --pattern regex: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -121,7 +122,7 @@ var MarkdownPatch = common.Shortcut{
|
||||
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return wrapMarkdownDownloadError(err)
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
original := string(payload)
|
||||
patched, matchCount, err := applyMarkdownPatch(original, spec)
|
||||
@@ -191,16 +192,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 markdownValidationParamError("--file-token", "%s", err).WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if !runtime.Changed("pattern") {
|
||||
return markdownValidationParamError("--pattern", "--pattern is required")
|
||||
return common.FlagErrorf("--pattern is required")
|
||||
}
|
||||
if spec.Pattern == "" {
|
||||
return markdownValidationParamError("--pattern", "--pattern cannot be empty")
|
||||
return output.ErrValidation("--pattern cannot be empty")
|
||||
}
|
||||
if !spec.ContentSet {
|
||||
return markdownValidationParamError("--content", "--content is required")
|
||||
return common.FlagErrorf("--content is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -211,7 +212,7 @@ func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, e
|
||||
}
|
||||
re, err := regexp.Compile(spec.Pattern)
|
||||
if err != nil {
|
||||
return "", 0, markdownValidationParamError("--pattern", "invalid --pattern regex: %s", err).WithCause(err)
|
||||
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
|
||||
}
|
||||
matches := re.FindAllStringIndex(original, -1)
|
||||
return re.ReplaceAllString(original, spec.Content), len(matches), nil
|
||||
|
||||
@@ -9,7 +9,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"
|
||||
)
|
||||
@@ -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.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
data, err := runtime.CallAPI("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 "", errs.NewValidationError(errs.SubtypeFailedPrecondition, "no sheets found in this spreadsheet")
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "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.ValidationErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
|
||||
return common.FlagErrorf("--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.ValidationErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
|
||||
return common.FlagErrorf("--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) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
|
||||
return "", fmt.Errorf("invalid cell reference: %s", cell)
|
||||
}
|
||||
colIndex := columnNameToIndex(matches[1])
|
||||
if colIndex < 1 {
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1]) //nolint:forbidigo // intermediate sentinel; sole caller buildRectRange discards it and falls back
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1])
|
||||
}
|
||||
rowIndex, err := strconv.Atoi(matches[2])
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -16,10 +15,10 @@ import (
|
||||
func parseValues2DJSON(raw string) ([][]interface{}, error) {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
if rows == nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--values invalid JSON, must be a 2D array").WithParam("--values")
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -47,7 +46,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -91,7 +90,7 @@ var SheetRead = common.Shortcut{
|
||||
params["valueRenderOption"] = renderOption
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -168,7 +167,7 @@ var SheetWrite = common.Shortcut{
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPI("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,
|
||||
@@ -248,7 +247,7 @@ var SheetAppend = common.Shortcut{
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPI("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,
|
||||
@@ -289,7 +288,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -337,7 +336,7 @@ var SheetFind = common.Shortcut{
|
||||
"find": findText,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
|
||||
data, err := runtime.CallAPI("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
|
||||
}
|
||||
@@ -374,7 +373,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id")).WithParam("--range")
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -416,7 +415,7 @@ var SheetReplace = common.Shortcut{
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(sheetID),
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -38,7 +38,7 @@ var SheetWriteImage = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return common.FlagErrorf("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
|
||||
return output.ErrValidation("cannot read image file: %s", 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.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPI("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, errs.NewValidationError(errs.SubtypeInvalidArgument, "no file I/O provider registered")
|
||||
return nil, output.ErrValidation("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, errs.NewValidationError(errs.SubtypeInvalidArgument, "image must be a regular file: %s", imagePath).WithParam("--image")
|
||||
return nil, output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024
|
||||
if stat.Size() > maxImageSize {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024).WithParam("--image")
|
||||
return nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
func wrapSheetWriteImageStatError(err error, imagePath string) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "image file not found: %s", imagePath).WithParam("--image").WithCause(err)
|
||||
return output.ErrValidation("image file not found: %s", imagePath)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot stat image file: %s", err).WithParam("--image").WithCause(err)
|
||||
return output.ErrValidation("cannot stat image file: %s", err)
|
||||
}
|
||||
|
||||
func wrapSheetWriteImageOpenError(err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe image path: %s", err).WithParam("--image").WithCause(err)
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read image file: %s", err).WithParam("--image").WithCause(err)
|
||||
return output.ErrValidation("cannot read image file: %s", err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -16,40 +15,40 @@ import (
|
||||
func validateBatchStyleData(raw string) error {
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a non-empty JSON array").WithParam("--data")
|
||||
return common.FlagErrorf("--data must be a non-empty JSON array")
|
||||
}
|
||||
for i, item := range arr {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d] must be an object with ranges and style", i).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
|
||||
}
|
||||
rangesRaw, ok := entry["ranges"]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges is required", i).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d].ranges is required", i)
|
||||
}
|
||||
ranges, ok := rangesRaw.([]interface{})
|
||||
if !ok || len(ranges) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges must be a non-empty array of strings", i).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
|
||||
}
|
||||
for j, r := range ranges {
|
||||
s, ok := r.(string)
|
||||
if !ok || s == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] must be a non-empty string", i, j).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
|
||||
}
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
|
||||
}
|
||||
}
|
||||
styleRaw, ok := entry["style"]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style is required", i).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d].style is required", i)
|
||||
}
|
||||
if _, ok := styleRaw.(map[string]interface{}); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data[%d].style must be a JSON object", i).WithParam("--data")
|
||||
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -75,14 +74,14 @@ var SheetSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := style.(map[string]interface{}); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be a JSON object, got %T", style).WithParam("--style")
|
||||
return common.FlagErrorf("--style must be a JSON object, got %T", style)
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -116,10 +115,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be valid JSON: %v", err).WithParam("--style")
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("PUT",
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -155,7 +154,7 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return validateBatchStyleData(runtime.Str("data"))
|
||||
},
|
||||
@@ -182,11 +181,11 @@ var SheetBatchSetStyle = common.Shortcut{
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be valid JSON: %v", err).WithParam("--data")
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPITyped("PUT",
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -243,7 +242,7 @@ var SheetMergeCells = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -272,7 +271,7 @@ var SheetMergeCells = common.Shortcut{
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -307,7 +306,7 @@ var SheetUnmergeCells = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
@@ -335,7 +334,7 @@ var SheetUnmergeCells = common.Shortcut{
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -28,7 +27,7 @@ func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -36,10 +35,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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array of strings: %v", flagName, err).WithParam("--" + flagName)
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
|
||||
}
|
||||
if typed == nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a JSON array, got null", flagName).WithParam("--" + flagName)
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
|
||||
}
|
||||
arr := make([]interface{}, len(typed))
|
||||
for i, s := range typed {
|
||||
@@ -54,12 +53,12 @@ func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(ranges) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ranges must not be empty").WithParam("--ranges")
|
||||
return nil, common.FlagErrorf("--ranges must not be empty")
|
||||
}
|
||||
for i, r := range ranges {
|
||||
s, _ := r.(string)
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
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 nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
|
||||
}
|
||||
}
|
||||
return ranges, nil
|
||||
@@ -71,7 +70,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
|
||||
return nil, err
|
||||
}
|
||||
if len(condValues) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--condition-values must not be empty").WithParam("--condition-values")
|
||||
return nil, common.FlagErrorf("--condition-values must not be empty")
|
||||
}
|
||||
|
||||
dv := map[string]interface{}{
|
||||
@@ -91,7 +90,7 @@ func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{},
|
||||
return nil, err
|
||||
}
|
||||
if 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")
|
||||
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
|
||||
}
|
||||
opts["colors"] = colors
|
||||
}
|
||||
@@ -124,7 +123,7 @@ var SheetSetDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
_, err := buildDropdownBody(runtime)
|
||||
return err
|
||||
@@ -148,7 +147,7 @@ var SheetSetDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", dataValidationBasePath(token), nil,
|
||||
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
@@ -215,7 +214,7 @@ var SheetUpdateDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
|
||||
map[string]interface{}{
|
||||
"ranges": ranges,
|
||||
"dataValidationType": "list",
|
||||
@@ -248,7 +247,7 @@ var SheetGetDropdown = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)").WithParam("--range")
|
||||
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -260,7 +259,7 @@ var SheetGetDropdown = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateDropdownToken(runtime)
|
||||
data, err := runtime.CallAPITyped("GET", dataValidationBasePath(token),
|
||||
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
|
||||
map[string]interface{}{
|
||||
"range": runtime.Str("range"),
|
||||
"dataValidationType": "list",
|
||||
@@ -320,7 +319,7 @@ var SheetDeleteDropdown = common.Shortcut{
|
||||
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("DELETE", dataValidationBasePath(token), nil,
|
||||
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
|
||||
map[string]interface{}{
|
||||
"dataValidationRanges": dvRanges,
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range must not be empty").WithParam("--range")
|
||||
return common.FlagErrorf("--range must not be empty")
|
||||
}
|
||||
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.CallAPITyped("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range or --filter-view-name")
|
||||
return common.FlagErrorf("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.CallAPITyped("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --filter-type, --compare-type, or --expected")
|
||||
return common.FlagErrorf("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.CallAPITyped("PUT",
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("GET",
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("GET",
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("DELETE",
|
||||
data, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--expected must be a JSON array (e.g. [\"6\"]), got: %s", s).WithParam("--expected")
|
||||
return output.ErrValidation("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -115,10 +115,10 @@ var SheetMediaUpload = common.Shortcut{
|
||||
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return "", nil, common.WrapInputStatErrorTyped(err, "file not found")
|
||||
return "", nil, common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
return filePath, stat, nil
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func validateFloatImageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return "", common.FlagErrorf("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--range prefix %q does not match --sheet-id %q", prefix, sheetID).WithParam("--range")
|
||||
return common.FlagErrorf("--range prefix %q does not match --sheet-id %q", prefix, sheetID)
|
||||
}
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --range, --width, --height, --offset-x, --offset-y to update")
|
||||
return common.FlagErrorf("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be >= 20 pixels, got %d", v).WithParam("--width")
|
||||
return common.FlagErrorf("--width must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("height") {
|
||||
if v := runtime.Int("height"); v < 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be >= 20 pixels, got %d", v).WithParam("--height")
|
||||
return common.FlagErrorf("--height must be >= 20 pixels, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-x") {
|
||||
if v := runtime.Int("offset-x"); v < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-x must be >= 0, got %d", v).WithParam("--offset-x")
|
||||
return common.FlagErrorf("--offset-x must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("offset-y") {
|
||||
if v := runtime.Int("offset-y"); v < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--offset-y must be >= 0, got %d", v).WithParam("--offset-y")
|
||||
return common.FlagErrorf("--offset-y must be >= 0, got %d", v)
|
||||
}
|
||||
}
|
||||
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.CallAPITyped("POST", floatImageBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("PATCH", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, body)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("GET", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("GET", floatImageBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
data, err := runtime.CallAPI("DELETE", floatImageItemPath(token, runtime.Str("sheet-id"), runtime.Str("float-image-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -32,7 +31,7 @@ var SheetAddDimension = common.Shortcut{
|
||||
}
|
||||
length := runtime.Int("length")
|
||||
if length < 1 || length > 5000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--length must be between 1 and 5000, got %d", length).WithParam("--length")
|
||||
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -52,7 +51,7 @@ var SheetAddDimension = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -92,10 +91,10 @@ var SheetInsertDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") <= runtime.Int("start-index") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be greater than --start-index").WithParam("--end-index")
|
||||
return common.FlagErrorf("--end-index must be greater than --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -132,7 +131,7 @@ var SheetInsertDimension = common.Shortcut{
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
|
||||
nil, body,
|
||||
)
|
||||
@@ -166,16 +165,16 @@ var SheetUpdateDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --visible or --fixed-size")
|
||||
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--fixed-size must be >= 1").WithParam("--fixed-size")
|
||||
return common.FlagErrorf("--fixed-size must be >= 1")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -212,7 +211,7 @@ var SheetUpdateDimension = common.Shortcut{
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("PUT",
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -254,13 +253,13 @@ var SheetMoveDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 0").WithParam("--start-index")
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if runtime.Int("destination-index") < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--destination-index must be >= 0").WithParam("--destination-index")
|
||||
return common.FlagErrorf("--destination-index must be >= 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -282,7 +281,7 @@ var SheetMoveDimension = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(runtime.Str("sheet-id")),
|
||||
@@ -325,10 +324,10 @@ var SheetDeleteDimension = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-index must be >= 1").WithParam("--start-index")
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-index must be >= --start-index").WithParam("--end-index")
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -349,7 +348,7 @@ var SheetDeleteDimension = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPITyped("DELETE",
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
|
||||
@@ -76,23 +76,6 @@ func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportDryRunRejectsUnsafeOutputPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetExport, []string{
|
||||
"+export",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--file-extension", "xlsx",
|
||||
"--output-path", "../escape.xlsx",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Fatalf("expected unsafe output-path validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ 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"
|
||||
)
|
||||
@@ -401,26 +402,38 @@ func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.
|
||||
t.Fatal("expected move failure, got nil")
|
||||
}
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err)
|
||||
}
|
||||
if p.Code != 1310211 {
|
||||
t.Fatalf("error code = %d, want 1310211", p.Code)
|
||||
if exitErr.Detail.Code != 1310211 {
|
||||
t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code)
|
||||
}
|
||||
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.Message, `sheet copied successfully as "sheet_copy"`) {
|
||||
t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message)
|
||||
}
|
||||
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, "do not retry +copy-sheet") {
|
||||
t.Fatalf("hint missing retry guard: %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)
|
||||
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)
|
||||
}
|
||||
if p.LogID != "log-move-failed" {
|
||||
t.Fatalf("log_id = %q, want %q", p.LogID, "log-move-failed")
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -20,63 +21,63 @@ func sheetBatchUpdatePath(token string) string {
|
||||
}
|
||||
|
||||
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
if err := common.ExactlyOne(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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--spreadsheet-token").WithCause(err)
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
if url == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must be a spreadsheet URL like https://.../sheets/<token>").WithParam("--url")
|
||||
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--url").WithCause(err)
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func validateSheetID(flagName, sheetID string) error {
|
||||
if strings.TrimSpace(sheetID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --%s", flagName).WithParam("--" + flagName)
|
||||
return common.FlagErrorf("specify --%s", flagName)
|
||||
}
|
||||
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSheetTitle(flagName, title string) error {
|
||||
if title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not be empty", flagName).WithParam("--" + flagName)
|
||||
return common.FlagErrorf("--%s must not be empty", flagName)
|
||||
}
|
||||
if strings.ContainsAny(title, "\t\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain tabs or line breaks", flagName).WithParam("--" + flagName)
|
||||
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
|
||||
}
|
||||
if err := validate.RejectControlChars(title, flagName); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--" + flagName).WithCause(err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
if len([]rune(title)) > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be <= 100 characters", flagName).WithParam("--" + flagName)
|
||||
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
|
||||
}
|
||||
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must not contain any of / \\ ? * [ ] :", flagName).WithParam("--" + flagName)
|
||||
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNonNegativeInt(flagName string, value int) error {
|
||||
if value < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be >= 0, got %d", flagName, value).WithParam("--" + flagName)
|
||||
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -286,18 +287,36 @@ 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
|
||||
@@ -310,22 +329,46 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
|
||||
sheetID,
|
||||
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
|
||||
detail := map[string]interface{}{
|
||||
"partial_success": true,
|
||||
"failed_step": "move_copied_sheet",
|
||||
"spreadsheet_token": token,
|
||||
"sheet_id": sheetID,
|
||||
"requested_index": index,
|
||||
"retry_command": retryCommand,
|
||||
}
|
||||
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).WithHint(hint).WithCause(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,
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
@@ -354,7 +397,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--lock-info").WithCause(err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,24 +405,24 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
if hasProtectConfig {
|
||||
lock := runtime.Str("lock")
|
||||
if !runtime.Changed("lock") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --lock when updating protection settings").WithParam("--lock")
|
||||
return common.FlagErrorf("specify --lock when updating protection settings")
|
||||
}
|
||||
if runtime.Changed("lock-info") && lock != "LOCK" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--lock-info requires --lock LOCK").WithParam("--lock-info")
|
||||
return common.FlagErrorf("--lock-info requires --lock LOCK")
|
||||
}
|
||||
if runtime.Changed("user-ids") {
|
||||
if lock != "LOCK" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --lock LOCK").WithParam("--user-ids")
|
||||
return common.FlagErrorf("--user-ids requires --lock LOCK")
|
||||
}
|
||||
if runtime.Str("user-id-type") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids requires --user-id-type").WithParam("--user-id-type")
|
||||
return common.FlagErrorf("--user-ids requires --user-id-type")
|
||||
}
|
||||
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-ids must not be empty").WithParam("--user-ids")
|
||||
return common.FlagErrorf("--user-ids must not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,7 +434,7 @@ func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
runtime.Changed("frozen-col-count") ||
|
||||
hasProtectConfig
|
||||
if !hasUpdate {
|
||||
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 common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -487,7 +530,7 @@ var SheetCreateSheet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -550,7 +593,7 @@ var SheetCopySheet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -561,7 +604,7 @@ var SheetCopySheet = common.Shortcut{
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
copiedSheetID, _ := out["sheet_id"].(string)
|
||||
moveResp, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
|
||||
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
|
||||
if err != nil {
|
||||
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
|
||||
}
|
||||
@@ -601,7 +644,7 @@ var SheetDeleteSheet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -664,7 +707,7 @@ var SheetUpdateSheet = common.Shortcut{
|
||||
params = map[string]interface{}{"user_id_type": userIDType}
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", sheetBatchUpdatePath(token), params, body)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ var SheetInfo = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --spreadsheet-token")
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,7 +55,7 @@ var SheetInfo = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
spreadsheetData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
|
||||
spreadsheetData, err := runtime.CallAPI("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
}
|
||||
}
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
}
|
||||
}
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--headers invalid JSON, must be a 1D array").WithParam("--headers")
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
}
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--data invalid JSON, must be a 2D array").WithParam("--data")
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
allRows = append(allRows, rows...)
|
||||
@@ -151,7 +151,7 @@ var SheetCreate = common.Shortcut{
|
||||
createData["folder_token"] = folderToken
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
if _, err := runtime.CallAPI("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,11 +211,8 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--sheet-id is required when --file-extension is csv").WithParam("--sheet-id")
|
||||
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -241,8 +238,10 @@ var SheetExport = common.Shortcut{
|
||||
outputPath := runtime.Str("output-path")
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
|
||||
if err := validateSheetExportOutputPath(runtime); err != nil {
|
||||
return err
|
||||
if outputPath != "" {
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
exportData := map[string]interface{}{
|
||||
@@ -254,7 +253,7 @@ var SheetExport = common.Shortcut{
|
||||
exportData["sub_id"] = sheetID
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -281,7 +280,7 @@ var SheetExport = common.Shortcut{
|
||||
}
|
||||
|
||||
if fileToken == "" {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "export task timed out").WithRetryable()
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
|
||||
@@ -299,7 +298,7 @@ var SheetExport = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return wrapSheetsNetworkErr(err, "download failed: %s", err)
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -308,7 +307,7 @@ var SheetExport = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
@@ -322,14 +321,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package backward
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wrapSheetsNetworkErr preserves typed boundary errors and only classifies raw
|
||||
// transport failures that still surface from stream/download paths.
|
||||
func wrapSheetsNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
func rejectLocalImageInBatch(fv flagView) error {
|
||||
if strings.TrimSpace(fv.Str("image")) != "" {
|
||||
return common.ValidationErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
|
||||
return common.FlagErrorf("--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.ValidationErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
|
||||
return nil, common.FlagErrorf("+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.ValidationErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
|
||||
return nil, common.FlagErrorf("+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.ValidationErrorf("--source-index must be >= 0")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("+sheet-move in +batch-update requires index")
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
|
||||
}
|
||||
if fv.Int("index") < 0 {
|
||||
return nil, common.ValidationErrorf("--index must be >= 0")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("operations[%d] must be a JSON object", index)
|
||||
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' field is required", index)
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
return nil, common.ValidationErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf(
|
||||
return nil, common.FlagErrorf(
|
||||
"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.ValidationErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf(
|
||||
return nil, common.FlagErrorf(
|
||||
"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.ValidationErrorf(
|
||||
return nil, common.FlagErrorf(
|
||||
"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.ValidationErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf("--operations must be a non-empty JSON array")
|
||||
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
|
||||
58
shortcuts/sheets/csv_put_guard_test.go
Normal file
58
shortcuts/sheets/csv_put_guard_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("csv", "", "")
|
||||
cmd.ParseFlags(nil)
|
||||
cmd.Flags().Set("csv", csvVal)
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
|
||||
// only when it names a real file (a forgotten @), while leaving genuine inline
|
||||
// content alone — including the case the old name-shape heuristic got wrong:
|
||||
// prose that merely ends in or mentions a filename.
|
||||
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
if err == nil {
|
||||
t.Fatal("expected guard error when --csv names an existing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
|
||||
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
|
||||
}
|
||||
|
||||
// Content that is not a real file must pass through unchanged.
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // prose ending in a filename — not a real file
|
||||
"remember to update data.csv", // mentions the real file but isn't its name
|
||||
"a,b\n1,2", // multi-cell CSV
|
||||
"hello world",
|
||||
"nope.csv", // path-shaped but no such file
|
||||
"",
|
||||
} {
|
||||
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
|
||||
t.Errorf("content %q must pass through, got: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ 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) {
|
||||
@@ -37,21 +38,6 @@ func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{
|
||||
"csv": "a,b",
|
||||
"start-cell": "C3",
|
||||
"range": "A1:H17",
|
||||
})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
|
||||
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
|
||||
// call instead of silently anchoring at the "A1" flag default. Standalone never
|
||||
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
||||
@@ -453,15 +453,16 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "shtNEW") {
|
||||
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "spreadsheet_token") {
|
||||
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.ValidationErrorf("--%s: %s", name, vErr.Error())
|
||||
return common.FlagErrorf("--%s: %s", name, vErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,40 +12,20 @@ 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.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
if err := common.ExactlyOne(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 "", sheetsValidationCauseForFlag("spreadsheet-token", err)
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -53,10 +33,10 @@ func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", sheetsValidationCauseForFlag("url", err)
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -84,18 +64,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.ExactlyOneTyped(runtime, "sheet-id", "sheet-name"); err != nil {
|
||||
if err := common.ExactlyOne(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 "", "", sheetsValidationCauseForFlag("sheet-id", err)
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return id, "", nil
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("sheet-name"))
|
||||
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
|
||||
return "", "", sheetsValidationCauseForFlag("sheet-name", err)
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return "", name, nil
|
||||
}
|
||||
@@ -136,26 +116,18 @@ func requireSheetSelector(sheetID, sheetName string) error {
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID == "" && sheetName == "" {
|
||||
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"),
|
||||
)
|
||||
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
|
||||
}
|
||||
if sheetID != "" && sheetName != "" {
|
||||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "mutually exclusive"),
|
||||
sheetsInvalidParam("sheet-name", "mutually exclusive"),
|
||||
)
|
||||
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
|
||||
}
|
||||
if sheetID != "" {
|
||||
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
|
||||
return sheetsValidationCauseForFlag("sheet-id", err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
} else {
|
||||
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
|
||||
return sheetsValidationCauseForFlag("sheet-name", err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -180,19 +152,15 @@ func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string)
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID != "" && sheetName != "" {
|
||||
return common.ValidationErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName).
|
||||
WithParams(
|
||||
sheetsInvalidParam(idFlagName, "mutually exclusive"),
|
||||
sheetsInvalidParam(nameFlagName, "mutually exclusive"),
|
||||
)
|
||||
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
|
||||
}
|
||||
if sheetID != "" {
|
||||
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
|
||||
return sheetsValidationCauseForFlag(idFlagName, err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
} else if sheetName != "" {
|
||||
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
|
||||
return sheetsValidationCauseForFlag(nameFlagName, err)
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -229,7 +197,7 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
@@ -248,11 +216,11 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, sheetsValidationForFlag(name, "--%s is required", name)
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag(name, "--%s must be a JSON object", name)
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object", name)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -264,11 +232,11 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, sheetsValidationForFlag(name, "--%s is required", name)
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
}
|
||||
a, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag(name, "--%s must be a JSON array", name)
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array", name)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -325,7 +293,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
return nil, common.FlagErrorf("--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -339,10 +307,5 @@ func requireAnyStyleFlag(runtime flagView) error {
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
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"),
|
||||
)
|
||||
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
|
||||
}
|
||||
|
||||
@@ -6,13 +6,11 @@ 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"
|
||||
@@ -81,71 +79,6 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestSheetHelpersValidationMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing sheet selector reports both params", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := requireSheetSelector("", "")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("params = %#v, want two structured params", validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
|
||||
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "sheets"}
|
||||
cmd.Flags().String("url", "not-a-sheet-url", "")
|
||||
cmd.Flags().String("spreadsheet-token", "", "")
|
||||
_, err := resolveSpreadsheetToken(common.TestNewRuntimeContext(cmd, testConfig(t)))
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != "--url" {
|
||||
t.Fatalf("param = %q, want --url", validationErr.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet selector control char keeps param and cause", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := requireSheetSelector("bad\x00id", "")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != "--sheet-id" {
|
||||
t.Fatalf("param = %q, want --sheet-id", validationErr.Param)
|
||||
}
|
||||
if validationErr.Unwrap() == nil {
|
||||
t.Fatalf("expected control-char validation cause to be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json flag keeps param and cause", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := newMapFlagViewForCommand("+cells-set", map[string]interface{}{"cells": "{"})
|
||||
_, err := parseJSONFlag(fv, "cells")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != "--cells" {
|
||||
t.Fatalf("param = %q, want --cells", validationErr.Param)
|
||||
}
|
||||
if validationErr.Unwrap() == nil {
|
||||
t.Fatalf("expected JSON parse cause to be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// parseDryRunBody runs the shortcut in --dry-run and returns the first
|
||||
// api call's body. The dry-run output format is:
|
||||
//
|
||||
|
||||
@@ -132,7 +132,7 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, common.ValidationErrorf("--operations is required")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("range %q: %v", rng, err)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
return common.FlagErrorf("--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.ValidationErrorf("range %q: %v", rng, err)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf("--ranges[%d] must be a string", i)
|
||||
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.Contains(s, "!") {
|
||||
return nil, common.ValidationErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--ranges[%d]: %v", i, err)
|
||||
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
|
||||
}
|
||||
if _, _, err := rangeDimensions(sub); err != nil {
|
||||
return nil, common.ValidationErrorf("--ranges[%d] (%q): %v", i, s, err)
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("range %q must use sheet!range form", rng)
|
||||
return "", "", common.FlagErrorf("range %q must use sheet!range form", rng)
|
||||
}
|
||||
return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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.ValidationErrorf("--%s is required", spec.idFlag)
|
||||
return nil, common.FlagErrorf("--%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.ValidationErrorf("--%s is required", spec.idFlag)
|
||||
return nil, common.FlagErrorf("--%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.ValidationErrorf("+sparkline-update properties.sparklines must be an array")
|
||||
return common.FlagErrorf("+sparkline-update properties.sparklines must be an array")
|
||||
}
|
||||
for i, item := range arr {
|
||||
m, _ := item.(map[string]interface{})
|
||||
if m == nil {
|
||||
return common.ValidationErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
|
||||
return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i)
|
||||
}
|
||||
id, _ := m["sparkline_id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
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 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 nil
|
||||
@@ -595,20 +595,20 @@ func floatImageProperties(runtime flagView, uploadedImageToken string, requireIm
|
||||
}
|
||||
}
|
||||
if set == 0 && requireImageSource {
|
||||
return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required")
|
||||
return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required")
|
||||
}
|
||||
if set > 1 {
|
||||
return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive")
|
||||
return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive")
|
||||
}
|
||||
name := floatImageName(runtime)
|
||||
if name == "" {
|
||||
return nil, common.ValidationErrorf("--image-name is required")
|
||||
return nil, common.FlagErrorf("--image-name is required")
|
||||
}
|
||||
if !runtime.Changed("position-row") || !runtime.Changed("position-col") {
|
||||
return nil, common.ValidationErrorf("--position-row and --position-col are required")
|
||||
return nil, common.FlagErrorf("--position-row and --position-col are required")
|
||||
}
|
||||
if !runtime.Changed("size-width") || !runtime.Changed("size-height") {
|
||||
return nil, common.ValidationErrorf("--size-width and --size-height are required")
|
||||
return nil, common.FlagErrorf("--size-width and --size-height are required")
|
||||
}
|
||||
props := map[string]interface{}{
|
||||
"image_name": name,
|
||||
@@ -626,9 +626,7 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
if uploadedImageToken != "" {
|
||||
props["image_token"] = uploadedImageToken
|
||||
@@ -748,7 +746,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(img)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatErrorTyped(err)
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: img,
|
||||
@@ -764,7 +762,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.ValidationErrorf("--float-image-id is required")
|
||||
return nil, common.FlagErrorf("--float-image-id is required")
|
||||
}
|
||||
props, err := floatImageProperties(runtime, uploadedImageToken, op == "create")
|
||||
if err != nil {
|
||||
@@ -884,7 +882,7 @@ func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
props := map[string]interface{}{
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
@@ -959,10 +957,10 @@ func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[
|
||||
return nil, err
|
||||
}
|
||||
if sheetID == "" {
|
||||
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)")
|
||||
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)")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
props, err := requireJSONObject(runtime, "properties")
|
||||
if err != nil {
|
||||
@@ -1033,7 +1031,7 @@ func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[
|
||||
return nil, err
|
||||
}
|
||||
if sheetID == "" {
|
||||
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)")
|
||||
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)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
|
||||
@@ -5,9 +5,10 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -75,7 +76,7 @@ func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[st
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -107,22 +108,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 {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) || ee.Detail == nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(p.Message), "embedded block") {
|
||||
if !strings.Contains(strings.ToLower(ee.Detail.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 p.Hint == "" {
|
||||
p.Hint = hint
|
||||
if ee.Detail.Hint == "" {
|
||||
ee.Detail.Hint = hint
|
||||
} else {
|
||||
p.Hint += "; " + hint
|
||||
ee.Detail.Hint += "; " + hint
|
||||
}
|
||||
return err
|
||||
return ee
|
||||
}
|
||||
|
||||
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
|
||||
@@ -190,7 +191,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -344,36 +345,36 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("range") {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
return nil, common.FlagErrorf("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.ValidationErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
}
|
||||
if dimension == "column" && typ == "auto" {
|
||||
return nil, common.ValidationErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--type pixel requires --size <px>")
|
||||
return nil, common.FlagErrorf("--type pixel requires --size <px>")
|
||||
}
|
||||
if typ != "pixel" && hasSize {
|
||||
return nil, common.ValidationErrorf("--size is only valid with --type pixel")
|
||||
return nil, common.FlagErrorf("--size is only valid with --type pixel")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -566,10 +567,10 @@ func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op stri
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("source-range")) == "" {
|
||||
return nil, common.ValidationErrorf("--source-range is required")
|
||||
return nil, common.FlagErrorf("--source-range is required")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("target-range")) == "" {
|
||||
return nil, common.ValidationErrorf("--target-range is required")
|
||||
return nil, common.FlagErrorf("--target-range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -608,10 +609,10 @@ func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("source-range")) == "" {
|
||||
return nil, common.ValidationErrorf("--source-range is required")
|
||||
return nil, common.FlagErrorf("--source-range is required")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("target-range")) == "" {
|
||||
return nil, common.ValidationErrorf("--target-range is required")
|
||||
return nil, common.FlagErrorf("--target-range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -640,7 +641,7 @@ func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
// requireJSONArray runs the embedded JSON Schema for --sort-keys
|
||||
// via parseJSONFlag → validateParsedJSONFlag, so each item is
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -16,35 +16,34 @@ func TestAnnotateEmbeddedBlockClearErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) {
|
||||
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")
|
||||
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")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+pivot-delete") {
|
||||
t.Errorf("hint should point at +pivot-delete, got %q", p.Hint)
|
||||
if !strings.Contains(ee.Detail.Hint, "+pivot-delete") {
|
||||
t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends to existing hint", func(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ var CellsGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.ValidationErrorf("--range is required")
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -142,7 +142,7 @@ var CsvGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.ValidationErrorf("--range is required")
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -484,7 +484,7 @@ var DropdownGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.ValidationErrorf("--range is required")
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ var CellsSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("find")) == "" {
|
||||
return common.ValidationErrorf("--find is required")
|
||||
return common.FlagErrorf("--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.ValidationErrorf("--find is required")
|
||||
return nil, common.FlagErrorf("--find is required")
|
||||
}
|
||||
if !runtime.Changed("replacement") {
|
||||
return nil, common.ValidationErrorf("--replacement is required (pass an empty string to delete matches)")
|
||||
return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
|
||||
@@ -164,18 +164,18 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("position") {
|
||||
return nil, common.ValidationErrorf("--position is required")
|
||||
return nil, common.FlagErrorf("--position is required")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, common.ValidationErrorf("--count is required")
|
||||
return nil, common.FlagErrorf("--count is required")
|
||||
}
|
||||
position := strings.TrimSpace(runtime.Str("position"))
|
||||
if _, _, err := parseA1Position(position); err != nil {
|
||||
return nil, common.ValidationErrorf("invalid --position %q: %v", position, err)
|
||||
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
|
||||
}
|
||||
count := runtime.Int("count")
|
||||
if count <= 0 {
|
||||
return nil, common.ValidationErrorf("--count must be > 0 (got %d)", count)
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--dimension is required")
|
||||
return nil, common.FlagErrorf("--dimension is required")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, common.ValidationErrorf("--count is required (0 unfreezes)")
|
||||
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, common.ValidationErrorf("--count must be >= 0")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if _, _, _, err := parseA1Range(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
return nil, common.FlagErrorf("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.CallAPITyped("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
|
||||
data, err := runtime.CallAPI("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.ValidationErrorf("--source-range and --target are required")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("invalid --source-range %q: %v", src, err)
|
||||
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
|
||||
}
|
||||
tgt := strings.TrimSpace(runtime.Str("target"))
|
||||
tgtDim, tgtIdx, err := parseA1Position(tgt)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("invalid --target %q: %v", tgt, err)
|
||||
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
|
||||
}
|
||||
if tgtDim != dim {
|
||||
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
|
||||
return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
|
||||
}
|
||||
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -122,13 +122,13 @@ var SheetCreate = common.Shortcut{
|
||||
|
||||
func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
return nil, common.FlagErrorf("--title is required")
|
||||
}
|
||||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||||
return nil, common.FlagErrorf("--row-count must be between 0 and 50000")
|
||||
}
|
||||
if n := runtime.Int("col-count"); n < 0 || n > 200 {
|
||||
return nil, common.ValidationErrorf("--col-count must be between 0 and 200")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--title is required")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--color is required (empty string clears)")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--index is required")
|
||||
return common.FlagErrorf("--index is required")
|
||||
}
|
||||
if runtime.Int("index") < 0 {
|
||||
return common.ValidationErrorf("--index must be >= 0")
|
||||
return common.FlagErrorf("--index must be >= 0")
|
||||
}
|
||||
if runtime.Changed("source-index") && runtime.Int("source-index") < 0 {
|
||||
return common.ValidationErrorf("--source-index must be >= 0")
|
||||
return common.FlagErrorf("--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.ValidationErrorf("--title is required")
|
||||
return common.FlagErrorf("--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.ValidationErrorf("--headers must be a JSON array")
|
||||
return common.FlagErrorf("--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.ValidationErrorf("--values must be a JSON 2D array")
|
||||
return common.FlagErrorf("--values must be a JSON 2D array")
|
||||
}
|
||||
for i, r := range rows {
|
||||
if _, ok := r.([]interface{}); !ok {
|
||||
return common.ValidationErrorf("--values[%d] must be an array", i)
|
||||
return common.FlagErrorf("--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.CallAPITyped("POST", "/open-apis/sheets/v3/spreadsheets", nil, body)
|
||||
data, err := runtime.CallAPI("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 errs.NewInternalError(errs.SubtypeInvalidResponse, "spreadsheet created but token missing in response")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{"spreadsheet": ss}
|
||||
@@ -665,9 +665,19 @@ 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, _ 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")
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildInitialFillInput zips --headers + --values into a single set_cell_range
|
||||
@@ -755,7 +765,7 @@ var WorkbookExport = common.Shortcut{
|
||||
ext = "xlsx"
|
||||
}
|
||||
if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
|
||||
return common.ValidationErrorf("--sheet-id is required when --file-extension=csv")
|
||||
return common.FlagErrorf("--sheet-id is required when --file-extension=csv")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -803,13 +813,13 @@ var WorkbookExport = common.Shortcut{
|
||||
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
|
||||
body["sub_id"] = sid
|
||||
}
|
||||
taskData, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticket := common.GetString(taskData, "ticket")
|
||||
if ticket == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket missing")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
@@ -837,9 +847,9 @@ var WorkbookExport = common.Shortcut{
|
||||
continue
|
||||
default: // any non-zero status outside the in-progress window is a failure
|
||||
if 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: %s", ticket, status.JobErrorMsg)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task %s failed with job_status=%d", ticket, status.JobStatus)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
|
||||
}
|
||||
}
|
||||
if fileToken == "" {
|
||||
@@ -877,7 +887,7 @@ type exportTaskStatus struct {
|
||||
}
|
||||
|
||||
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
|
||||
map[string]interface{}{"token": token},
|
||||
@@ -888,7 +898,7 @@ func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (expor
|
||||
}
|
||||
result := common.GetMap(data, "result")
|
||||
if result == nil {
|
||||
return exportTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "export task %s: empty result", ticket)
|
||||
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
|
||||
}
|
||||
js, _ := util.ToFloat64(result["job_status"])
|
||||
fs, _ := util.ToFloat64(result["file_size"])
|
||||
@@ -908,10 +918,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 "", sheetsDownloadRequestError(err)
|
||||
return "", output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
return "", sheetsDownloadHTTPStatusError(apiResp)
|
||||
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
|
||||
}
|
||||
target := outPath
|
||||
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
|
||||
@@ -925,7 +935,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.WrapSaveErrorTyped(err)
|
||||
return "", common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
resolved, _ := runtime.FileIO().ResolvePath(target)
|
||||
if resolved == "" {
|
||||
@@ -934,57 +944,6 @@ 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.
|
||||
@@ -997,7 +956,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||||
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
|
||||
}
|
||||
sheets, _ := m["sheets"].([]interface{})
|
||||
for _, raw := range sheets {
|
||||
@@ -1016,7 +975,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, errs.NewInternalError(errs.SubtypeInvalidResponse, "sheet entry missing index field")
|
||||
return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field")
|
||||
}
|
||||
return id, int(idx), nil
|
||||
}
|
||||
@@ -1025,7 +984,7 @@ func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if target == "" {
|
||||
target = sheetName
|
||||
}
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeFailedPrecondition, "sheet %q not found in workbook", target)
|
||||
return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target))
|
||||
}
|
||||
|
||||
// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the
|
||||
@@ -1042,7 +1001,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||||
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output")
|
||||
}
|
||||
sheets, _ := m["sheets"].([]interface{})
|
||||
bestID := ""
|
||||
@@ -1070,7 +1029,7 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
|
||||
}
|
||||
}
|
||||
if bestID == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
|
||||
return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets")
|
||||
}
|
||||
return bestID, nil
|
||||
}
|
||||
|
||||
@@ -4,14 +4,9 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -396,92 +391,6 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves typed request errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
|
||||
got := sheetsDownloadRequestError(in)
|
||||
if got != in {
|
||||
t.Fatalf("typed error was not preserved: got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadRequestError(errors.New("dial refused"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantCategory errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetryable bool
|
||||
}{
|
||||
{
|
||||
name: "5xx is retryable network server error",
|
||||
status: http.StatusBadGateway,
|
||||
wantCategory: errs.CategoryNetwork,
|
||||
wantSubtype: errs.SubtypeNetworkServer,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "404 is API not found",
|
||||
status: http.StatusNotFound,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeNotFound,
|
||||
},
|
||||
{
|
||||
name: "429 is retryable API rate limit",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeRateLimit,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "other 4xx is API unknown",
|
||||
status: http.StatusForbidden,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
|
||||
StatusCode: tt.status,
|
||||
RawBody: []byte("body"),
|
||||
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
|
||||
})
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
|
||||
}
|
||||
if p.Code != tt.status {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tt.status)
|
||||
}
|
||||
if p.LogID != "log123" {
|
||||
t.Fatalf("log_id = %q, want log123", p.LogID)
|
||||
}
|
||||
if p.Retryable != tt.wantRetryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertInputEquals compares the decoded tool input map against the wanted
|
||||
// fields. Extra fields in `got` are allowed (defaults, optional fields);
|
||||
// every key in `want` must match exactly.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--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.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
|
||||
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return nil, err
|
||||
@@ -218,9 +218,13 @@ var CsvPut = common.Shortcut{
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||||
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
|
||||
},
|
||||
Validate: validateViaInput(csvPutInput),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := guardCSVValueIsNotFilePath(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateViaInput(csvPutInput)(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -296,15 +300,42 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
|
||||
return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true
|
||||
}
|
||||
|
||||
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
|
||||
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
|
||||
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
|
||||
// would otherwise be written silently as the literal text "data.csv". It runs
|
||||
// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is
|
||||
// already its contents (a real CSV blob, never a path) and only a bare value
|
||||
// reaches here unchanged. It flags the value only when it actually names an
|
||||
// existing file in the cwd subtree; checking real existence (not name shape)
|
||||
// means inline content that merely ends in a filename ("see config.json") is
|
||||
// never misjudged. Fails open: any Stat error or a directory leaves the value
|
||||
// untouched. Scoped to --csv only — no other flag is affected.
|
||||
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
|
||||
raw := strings.TrimSpace(runtime.Str("csv"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return nil
|
||||
}
|
||||
info, err := fio.Stat(raw)
|
||||
if err != nil || info == nil || info.IsDir() {
|
||||
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
|
||||
}
|
||||
return common.FlagErrorf(
|
||||
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
|
||||
raw, raw,
|
||||
)
|
||||
}
|
||||
|
||||
func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("csv")) == "" {
|
||||
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")
|
||||
return nil, common.FlagErrorf("--csv is required")
|
||||
}
|
||||
anchor := strings.TrimSpace(runtime.Str("start-cell"))
|
||||
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
|
||||
@@ -315,24 +346,23 @@ 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 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.
|
||||
// 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.
|
||||
if !runtime.Changed("start-cell") {
|
||||
rng := strings.TrimSpace(runtime.Str("range"))
|
||||
if rng == "" {
|
||||
return nil, common.ValidationErrorf("--start-cell or --range is required")
|
||||
return nil, common.FlagErrorf("--start-cell or --range is required")
|
||||
}
|
||||
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
|
||||
}
|
||||
if anchor == "" {
|
||||
return nil, common.ValidationErrorf("--start-cell is required")
|
||||
return nil, common.FlagErrorf("--start-cell is required")
|
||||
}
|
||||
if _, _, ok := splitCellRef(anchor); !ok {
|
||||
return nil, common.ValidationErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
|
||||
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
@@ -403,11 +433,11 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if rangeStr == "" {
|
||||
return nil, common.ValidationErrorf("--range is required")
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--range %q: %v", rangeStr, err)
|
||||
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
|
||||
}
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
@@ -466,7 +496,7 @@ func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) > sourceSize {
|
||||
return nil, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
}
|
||||
dv["highlight_colors"] = colors
|
||||
}
|
||||
@@ -488,9 +518,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.ValidationErrorf("--options and --source-range are mutually exclusive; pass exactly one")
|
||||
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
|
||||
case optsRaw == "" && sourceRange == "":
|
||||
return 0, nil, common.ValidationErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
|
||||
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
|
||||
case optsRaw != "":
|
||||
options, err := requireJSONArray(runtime, "options")
|
||||
if err != nil {
|
||||
@@ -503,7 +533,7 @@ func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error)
|
||||
default: // sourceRange != ""
|
||||
rows, cols, err := rangeDimensions(sourceRange)
|
||||
if err != nil {
|
||||
return 0, nil, common.ValidationErrorf("--source-range %q: %v", sourceRange, err)
|
||||
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
|
||||
}
|
||||
return rows * cols, map[string]interface{}{
|
||||
"type": "listFromRange",
|
||||
@@ -528,7 +558,7 @@ func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
if len(colors) > sourceSize {
|
||||
return 0, common.ValidationErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
}
|
||||
}
|
||||
return sourceSize, nil
|
||||
@@ -701,18 +731,18 @@ var CellsSetImage = common.Shortcut{
|
||||
}
|
||||
r := strings.TrimSpace(runtime.Str("range"))
|
||||
if r == "" {
|
||||
return common.ValidationErrorf("--range is required")
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(r)
|
||||
if err != nil {
|
||||
return common.ValidationErrorf("--range %q: %v", r, err)
|
||||
return common.FlagErrorf("--range %q: %v", r, err)
|
||||
}
|
||||
if rows != 1 || cols != 1 {
|
||||
return common.ValidationErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
|
||||
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
|
||||
}
|
||||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||||
if imgPath == "" {
|
||||
return common.ValidationErrorf("--image is required")
|
||||
return common.FlagErrorf("--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.
|
||||
@@ -720,9 +750,7 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -778,18 +806,16 @@ var CellsSetImage = common.Shortcut{
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(imgPath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatErrorTyped(err)
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
imgFile, err := runtime.FileIO().Open(imgPath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatErrorTyped(err)
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
imgCfg, _, err := image.DecodeConfig(imgFile)
|
||||
imgFile.Close()
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "decode image dimensions: %s", err).
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
return fmt.Errorf("decode image dimensions: %w", err)
|
||||
}
|
||||
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: imgPath,
|
||||
@@ -818,7 +844,7 @@ var CellsSetImage = common.Shortcut{
|
||||
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
|
||||
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
|
||||
if err != nil {
|
||||
return wrapCellsSetImageWriteError(err, fileToken)
|
||||
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
@@ -831,18 +857,3 @@ var CellsSetImage = common.Shortcut{
|
||||
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
|
||||
},
|
||||
}
|
||||
|
||||
func wrapCellsSetImageWriteError(err error, fileToken string) error {
|
||||
hint := fmt.Sprintf("image was uploaded as file_token=%s; retry only the cell write with that token or remove the uploaded media", fileToken)
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint += "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "image uploaded (file_token=%s) but cell write failed: %s", fileToken, err).
|
||||
WithHint(hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -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, errs.NewInternalError(errs.SubtypeSDKError, "encode tool input: %v", err).WithCause(err)
|
||||
return nil, fmt.Errorf("encode tool input: %w", err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": toolName,
|
||||
@@ -77,14 +77,13 @@ func callTool(
|
||||
|
||||
envelope, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
return nil, output.Errorf(output.ExitAPI, "tool_response",
|
||||
"tool %q: unexpected non-JSON-object response: %v", toolName, raw)
|
||||
}
|
||||
code, _ := util.ToFloat64(envelope["code"])
|
||||
if code != 0 {
|
||||
msg, _ := envelope["msg"].(string)
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
|
||||
WithCode(int(code))
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"])
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
rawOutput, _ := data["output"].(string)
|
||||
@@ -94,8 +93,8 @@ func callTool(
|
||||
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(rawOutput), &out); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"tool %q returned invalid JSON output: %v", toolName, err).WithCause(err)
|
||||
return nil, output.Errorf(output.ExitAPI, "tool_output",
|
||||
"tool %q returned invalid JSON output: %v", toolName, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--presentation cannot be empty").WithParam("--presentation")
|
||||
return presentationRef{}, output.ErrValidation("--presentation cannot be empty")
|
||||
}
|
||||
// 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{}, 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{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
}
|
||||
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{}, 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{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
}
|
||||
// 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{}, 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{}, output.ErrValidation("unsupported --presentation input %q: use an xml_presentation_id, a /slides/ URL, or a /wiki/ URL", raw)
|
||||
}
|
||||
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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType != "slides" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slides shortcuts require a slides presentation", objType).WithParam("--presentation")
|
||||
return "", output.ErrValidation("wiki resolved to %q, but slides shortcuts require a slides presentation", objType)
|
||||
}
|
||||
return objToken, nil
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported presentation ref kind %q", ref.Kind)
|
||||
return "", output.ErrValidation("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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "no root element found in XML fragment")
|
||||
return "", fmt.Errorf("no root element found in XML fragment")
|
||||
}
|
||||
prefix := xmlFragment[m[2]:m[3]]
|
||||
tagName := xmlFragment[m[4]:m[5]]
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides invalid JSON, must be an array of XML strings").WithParam("--slides")
|
||||
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
|
||||
}
|
||||
if len(slides) > 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")
|
||||
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
|
||||
}
|
||||
// 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 slidesInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("--slides @%s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides @%s: must be a regular file", path).WithParam("--slides")
|
||||
return common.FlagErrorf("--slides @%s: must be a regular file", path)
|
||||
}
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
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")
|
||||
return common.FlagErrorf("--slides @%s: file size %s exceeds 20 MB limit for slides image upload",
|
||||
path, common.FormatSize(stat.Size()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ var SlidesCreate = common.Shortcut{
|
||||
slidesStr := runtime.Str("slides")
|
||||
|
||||
// Step 1: Create presentation
|
||||
data, err := runtime.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
@@ -168,7 +168,9 @@ var SlidesCreate = common.Shortcut{
|
||||
if len(placeholders) > 0 {
|
||||
tokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, placeholders)
|
||||
if err != nil {
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("presentation %s was created; %d image(s) uploaded before failure", presentationID, uploaded))
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
|
||||
err, presentationID, uploaded)
|
||||
}
|
||||
for i := range slides {
|
||||
slides[i] = replaceImagePlaceholders(slides[i], tokens)
|
||||
@@ -183,7 +185,7 @@ var SlidesCreate = common.Shortcut{
|
||||
|
||||
var slideIDs []string
|
||||
for i, slideXML := range slides {
|
||||
slideData, err := runtime.CallAPITyped(
|
||||
slideData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": -1},
|
||||
@@ -192,7 +194,9 @@ var SlidesCreate = common.Shortcut{
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
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))
|
||||
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)
|
||||
}
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
@@ -252,10 +256,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, slidesInputStatError(err, fmt.Sprintf("@%s: file not found", path))
|
||||
return tokens, i, common.WrapInputStatError(err, fmt.Sprintf("@%s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return tokens, i, errs.NewValidationError(errs.SubtypeInvalidArgument, "@%s: must be a regular file", path).WithParam("--slides")
|
||||
return tokens, i, output.ErrValidation("@%s: must be a regular file", path)
|
||||
}
|
||||
fileName := filepath.Base(path)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading image %d/%d: %s (%s)\n",
|
||||
@@ -263,7 +267,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) //nolint:forbidigo // intermediate; preserves typed cause via %w, reclassified by appendSlidesProgressHint at the call site
|
||||
return tokens, i, fmt.Errorf("@%s: %w", path, err)
|
||||
}
|
||||
tokens[path] = token
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -401,21 +400,15 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %v", err)
|
||||
errMsg := err.Error()
|
||||
if !strings.Contains(errMsg, "pres_partial") {
|
||||
t.Fatalf("error should contain presentation ID, 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, "slide 2/2") {
|
||||
t.Fatalf("error should indicate slide 2/2 failed, 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)
|
||||
if !strings.Contains(errMsg, "1 slide(s) added") {
|
||||
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// slidesInputStatError maps a FileIO.Stat error for an input image path to a
|
||||
// typed validation error, prefixing the caller's context message. Both path
|
||||
// validation failures and other stat errors are user-actionable input problems
|
||||
// (exit code 2). Already-typed errors are not expected here (Stat returns raw
|
||||
// fs errors), so this always classifies as validation.
|
||||
func slidesInputStatError(err error, msg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: unsafe file path: %s", msg, err).WithCause(err)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
|
||||
}
|
||||
|
||||
// appendSlidesProgressHint preserves err's typed classification (per
|
||||
// ERROR_CONTRACT.md "propagate typed errors unchanged") and appends an
|
||||
// orchestration-progress hint — e.g. "presentation was created; N image(s)
|
||||
// uploaded before failure" — so a failure mid-sequence still tells the caller
|
||||
// what partial state exists. An unclassified error (e.g. surfaced from a shared
|
||||
// helper boundary before it can be classified) falls back to a typed internal
|
||||
// error carrying the hint.
|
||||
func appendSlidesProgressHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Hint != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -86,15 +86,15 @@ var SlidesMediaUpload = common.Shortcut{
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return slidesInputStatError(err, "file not found")
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
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")
|
||||
return output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
filepath.Base(filePath), common.FormatSize(stat.Size()))
|
||||
}
|
||||
|
||||
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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
return "", output.ErrValidation("file %s is %s, exceeds 20 MB limit for slides image upload",
|
||||
fileName, common.FormatSize(fileSize))
|
||||
}
|
||||
parent := presentationID
|
||||
|
||||
@@ -6,10 +6,11 @@ package slides
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -57,7 +58,7 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty")
|
||||
return common.FlagErrorf("--slide-id cannot be empty")
|
||||
}
|
||||
parts, err := parseReplaceParts(runtime.Str("parts"))
|
||||
if err != nil {
|
||||
@@ -152,7 +153,7 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide/replace",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
data, err := runtime.CallAPITyped("POST", url, query, body)
|
||||
data, err := runtime.CallAPI("POST", url, query, body)
|
||||
if err != nil {
|
||||
return enrichSlidesReplaceError(err)
|
||||
}
|
||||
@@ -200,11 +201,11 @@ type replacePart struct {
|
||||
func parseReplaceParts(raw string) ([]replacePart, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts cannot be empty")
|
||||
return nil, common.FlagErrorf("--parts cannot be empty")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts invalid JSON, must be an array of objects: %v", err)
|
||||
return nil, common.FlagErrorf("--parts invalid JSON, must be an array of objects: %v", err)
|
||||
}
|
||||
out := make([]replacePart, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
@@ -212,35 +213,35 @@ func parseReplaceParts(raw string) ([]replacePart, error) {
|
||||
if v, ok := m["action"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action must be a string", i)
|
||||
return nil, common.FlagErrorf("--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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement must be a string", i)
|
||||
return nil, common.FlagErrorf("--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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].block_id must be a string", i)
|
||||
return nil, common.FlagErrorf("--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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion must be a string", i)
|
||||
return nil, common.FlagErrorf("--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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insert_before_block_id must be a string", i)
|
||||
return nil, common.FlagErrorf("--parts[%d].insert_before_block_id must be a string", i)
|
||||
}
|
||||
p.InsertBeforeBlockID = &s
|
||||
}
|
||||
@@ -260,18 +261,17 @@ 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 {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != larkCodeSlidesInvalidParam {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.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. p points at the embedded Problem, so
|
||||
// the mutation is reflected in the returned err.
|
||||
if p.Hint == "" {
|
||||
p.Hint = slides3350001Hint
|
||||
// backend or an earlier wrapper.
|
||||
if exitErr.Detail.Hint == "" {
|
||||
exitErr.Detail.Hint = slides3350001Hint
|
||||
}
|
||||
return err
|
||||
return exitErr
|
||||
}
|
||||
|
||||
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item")
|
||||
return common.FlagErrorf("--parts must contain at least 1 item")
|
||||
}
|
||||
if len(parts) > maxReplaceParts {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts contains %d items, exceeds maximum of %d", len(parts), maxReplaceParts)
|
||||
return common.FlagErrorf("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty block_id", i)
|
||||
return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty block_id", i)
|
||||
}
|
||||
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i)
|
||||
return common.FlagErrorf("--parts[%d] (block_replace) requires non-empty replacement", i)
|
||||
}
|
||||
case "block_insert":
|
||||
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i)
|
||||
return common.FlagErrorf("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action)
|
||||
return common.FlagErrorf("--parts[%d] action %q is not supported by this shortcut; use block_replace or block_insert", i, p.Action)
|
||||
case "":
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].action is required", i)
|
||||
return common.FlagErrorf("--parts[%d].action is required", i)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] unknown action %q, supported: block_replace, block_insert", i, p.Action)
|
||||
return common.FlagErrorf("--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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithCause(err)
|
||||
return nil, output.ErrValidation("--parts[%d].replacement: %v", i, err)
|
||||
}
|
||||
fixed = ensureShapeHasContent(fixed)
|
||||
m["block_id"] = *p.BlockID
|
||||
|
||||
@@ -5,13 +5,14 @@ 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
|
||||
@@ -630,15 +631,15 @@ func TestReplaceSlide3350001ErrorEnrichment(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 3350001")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %v", err)
|
||||
}
|
||||
if p.Code != 3350001 {
|
||||
t.Fatalf("expected code 3350001, got %d", p.Code)
|
||||
if exitErr.Detail.Code != 3350001 {
|
||||
t.Fatalf("expected code 3350001, got %d", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -669,17 +670,17 @@ func TestReplaceSlideNon3350001ErrorNotEnriched(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if p.Code != 99991672 {
|
||||
t.Fatalf("expected code 99991672, got %d", p.Code)
|
||||
if exitErr.Detail.Code != 99991672 {
|
||||
t.Fatalf("expected code 99991672, got %d", exitErr.Detail.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(p.Hint, "common causes") {
|
||||
t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", p.Hint)
|
||||
if strings.Contains(exitErr.Detail.Hint, "common causes") {
|
||||
t.Fatalf("non-3350001 error should not get slides-specific hint, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -94,7 +95,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string {
|
||||
}
|
||||
|
||||
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
|
||||
// translate from runtime.CallAPITyped responses or test fakes.
|
||||
// translate from runtime.CallAPI responses or test fakes.
|
||||
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
|
||||
|
||||
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
|
||||
@@ -102,7 +103,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{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
}
|
||||
|
||||
result := common.GetMap(task, resultKey)
|
||||
@@ -166,7 +167,7 @@ func pollWikiAsyncTask(
|
||||
return status, true, nil
|
||||
}
|
||||
if status.Failed() {
|
||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "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())
|
||||
@@ -177,18 +178,29 @@ func pollWikiAsyncTask(
|
||||
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
|
||||
label, taskID, nextCommand,
|
||||
)
|
||||
// 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
|
||||
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,
|
||||
},
|
||||
}
|
||||
p.Hint = hint
|
||||
return lastStatus, false, lastErr
|
||||
}
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
|
||||
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
|
||||
}
|
||||
|
||||
return lastStatus, false, nil
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
|
||||
@@ -98,13 +98,16 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
|
||||
if ready {
|
||||
t.Fatalf("ready = true, want false when every poll failed")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
|
||||
}
|
||||
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 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(stderr.String(), "attempt 2/2 failed") {
|
||||
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
|
||||
@@ -115,10 +118,15 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
|
||||
// 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")
|
||||
upstream := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991663,
|
||||
Message: "permission denied",
|
||||
Hint: "grant the wiki:node:retrieve scope",
|
||||
},
|
||||
}
|
||||
_, _, err := pollWikiAsyncTask(
|
||||
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
|
||||
func(context.Context, string) (wikiAsyncTaskStatus, error) {
|
||||
@@ -126,23 +134,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
|
||||
},
|
||||
"resume-cmd",
|
||||
)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
|
||||
}
|
||||
// The upstream hint must lead so the actionable cause is read first, with
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", p.Hint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
|
||||
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
|
||||
}
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype)
|
||||
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
|
||||
}
|
||||
if p.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", p.Message)
|
||||
if exitErr.Detail.Message != "permission denied" {
|
||||
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct {
|
||||
}
|
||||
|
||||
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
|
||||
data, err := api.runtime.CallAPITyped(
|
||||
data, err := api.runtime.CallAPI(
|
||||
"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.CallAPITyped(
|
||||
data, err := api.runtime.CallAPI(
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
return output.ErrValidation("--space-id is required")
|
||||
}
|
||||
return validateOptionalResourceName(spec.SpaceID, "--space-id")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package wiki
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -13,12 +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/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -266,18 +266,19 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
withSingleWikiDeleteSpacePoll(t)
|
||||
|
||||
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
|
||||
// Seed a typed error that carries an upstream Lark code and hint so the test
|
||||
// Seed an error that carries an upstream Lark Detail.Code so the test
|
||||
// pins that structured fields survive a fully failed poll (not just the
|
||||
// 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"
|
||||
}
|
||||
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
|
||||
client := &fakeWikiDeleteSpaceClient{
|
||||
taskErrs: []error{seeded},
|
||||
taskErrs: []error{&output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 131006,
|
||||
Message: "poll failed",
|
||||
Hint: "retry original",
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
|
||||
@@ -290,15 +291,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
|
||||
if status.TaskID != "task_123" {
|
||||
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %T %v", err, err)
|
||||
}
|
||||
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 !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 p.Code != 131006 {
|
||||
t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code)
|
||||
if exitErr.Detail.Code != 131006 {
|
||||
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
|
||||
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody())
|
||||
data, err := runtime.CallAPI("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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
|
||||
}
|
||||
// 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{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return wikiMemberAddSpec{}, output.ErrValidation(
|
||||
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
|
||||
).WithParam("--member-type")
|
||||
)
|
||||
}
|
||||
// --member-type / --member-role enum membership is enforced by the
|
||||
// framework's validateEnumFlags (runner.go) before Validate runs, so no
|
||||
|
||||
@@ -6,7 +6,7 @@ package wiki
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id")
|
||||
return output.ErrValidation("--space-id is required and cannot be blank")
|
||||
}
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
|
||||
}
|
||||
return validateOptionalResourceName(spaceID, "--space-id")
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
data, err := runtime.CallAPI("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user