Compare commits

...

7 Commits

Author SHA1 Message Date
huangmengxuan
0ff9c84cd8 feat(skill): add relative-path-only rule to lark-shared security section
lark-cli rejects absolute paths for --file, --output, --output-dir,
and @file with 'unsafe file path'. Document this in lark-shared so
agents know to use cwd-relative paths or stdin for data input.

Change-Id: I50cf801c2c5d0e3cbb98a76e1752d410518c8636
2026-06-03 13:59:06 +08:00
huangmengxuan
175c9f6ffc feat(skill): add CRITICAL instruction to lark-task, lark-contact, lark-slides
Same enforcement as the previous commit — require reading reference
docs (or -h) before calling shortcuts. These three skills use
non-standard section headers but still have shortcut tables with
reference links.

Change-Id: I5170cc763c15e3030c4117a36af36c9f1e94501e
2026-06-03 13:59:06 +08:00
huangmengxuan
575bcc407b feat(skill): add CRITICAL instruction to enforce reference reading before shortcut execution
Evaluation data shows AI call failure rate <1% when reference docs are
read vs ~29% when not. Add a CRITICAL line to the Shortcuts section of
14 SKILL.md files and the skill template, requiring agents to read the
linked reference doc (or run -h for commands without one) before
invoking any shortcut.

Change-Id: Ia4204518eb43a9f6c8295b95633ee5d9cf2f5352
2026-06-03 13:59:06 +08:00
MaxHuang22
24ce3ec151 feat: add --json flag as no-op alias for --format json (#1104)
* feat(api): add --json flag as no-op alias for --format json

* feat(service): add --json flag as no-op alias for --format json

* feat(shortcut): add --json flag as no-op alias for --format json

Skip registration when a custom --json flag already exists on the
command (e.g. base shortcuts use --json for body input).

Change-Id: If66236cadeea7fa81811061cce775deff51b92ce
2026-06-03 13:58:14 +08:00
dc-bytedance
2bbab4d851 feat: validate credentials after config init (#1151)
* refactor: extract FetchTAT sharing the TAT-rejection classifier

doResolveTAT minted the tenant access token inline. Extract the HTTP call
into FetchTAT(ctx, httpClient, brand, appID, appSecret) so callers that
already hold plaintext credentials — notably the post-config-init probe —
can validate them without a second keychain round-trip.

FetchTAT routes a non-zero TAT body code through the same
classifyTATResponseCode the credential layer already uses, so a rejection is
the canonical CategoryConfig / SubtypeInvalidClient (10003 / 10014) typed
error — identical to what every token-resolving command returns. Transport,
HTTP-status and JSON-parse failures stay raw (untyped) so callers can use
errs.IsTyped to separate a deterministic credential rejection from upstream
noise. doResolveTAT now delegates to FetchTAT; observable behavior unchanged.

* feat: validate credentials after config init

After config init saves the App ID / App Secret, fire a best-effort probe:
mint a tenant access token with the just-saved credentials, then POST the
application probe endpoint. When the credentials are deterministically
rejected, FetchTAT returns a typed errs.* error and runProbe propagates it,
so config init exits non-zero with the canonical ConfigError / invalid_client
envelope (the same one every other command shows for the same bad creds)
instead of letting the user discover the mistake on a later request.

Ambiguous failures (transport, HTTP non-200, JSON parse, timeout,
http-client init) come back untyped and are swallowed (errs.IsTyped is the
discriminator), so a valid configuration is never blocked by upstream noise.
The probe is wired into all four init paths and skipped when the user reused
an existing secret. The saved config is not rolled back on rejection: stdout
still records what was saved, stderr carries the typed error envelope.
2026-06-03 11:44:04 +08:00
evandance
98173ae5a9 feat(drive): emit typed error envelopes across the drive domain (#1205)
Drive-domain errors now leave the CLI as typed, machine-branchable
envelopes — a stable `type` plus `subtype` and named fields (param,
params, retryable, log_id, hint) — so scripts and AI agents can branch on
structure and act on a recovery hint instead of parsing prose.

Changes:
- Every error produced in the drive domain — validation, file I/O, and the
  failures returned from its Lark API calls — is emitted as a typed errs.*
  error; the exit code is derived from the error category. Drive's API calls
  now go through a shared typed classifier, so failures carry subtype,
  troubleshooter, a recovery hint, and the request's log_id whether the
  server returns it in the response body or the x-tt-logid header; an
  already-typed network/auth error is never downgraded into a generic API
  error.
- Known API conditions (resource conflict, cross-tenant, cross-brand, ...)
  carry a recovery hint keyed by their error class; a command can refine
  that hint with command-specific guidance.
- Batch partial failures (+push / +pull / +sync, where some items succeed
  and some fail) now report an honest ok:false multi-status result on
  stdout — the summary and every per-item outcome stay machine-readable —
  and exit non-zero, instead of a misleading ok:true success envelope.
- Duplicate rel_path conflicts report each colliding path as a structured
  params entry (RFC 7807 invalid-params style).
- Static guards lock the drive path so legacy error construction — direct
  envelopes or the auto-classifying API helpers — cannot be reintroduced,
  making drive the template for the remaining domains.

Output changes worth noting for consumers:
- Error envelopes now carry typed type/subtype and named fields; exit
  codes follow the error category (malformed or incomplete API responses
  are reported as internal errors rather than generic API errors).
- Batch partial failures (+push / +pull / +sync) emit an ok:false result
  envelope on stdout (summary + per-item items[]) and exit non-zero; the
  per-item results stay on stdout rather than in a stderr error envelope.

Errors surfaced through shared cross-domain helpers (scope precheck, media
import upload, metadata lookup, save-path resolution) are not yet typed;
they migrate with the shared layer in a follow-up change.
2026-06-03 10:27:15 +08:00
zhangheng023
c8e205eed2 fix: recover toUpdate skills empty fallback (#1233) 2026-06-02 23:26:16 +08:00
88 changed files with 3014 additions and 854 deletions

View File

@@ -65,10 +65,23 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -94,6 +107,23 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are

View File

@@ -90,6 +90,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")

View File

@@ -718,3 +718,23 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -341,6 +341,9 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil
}
@@ -380,6 +383,9 @@ func configInitRun(opts *ConfigInitOptions) error {
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
@@ -419,6 +425,11 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil
}
@@ -507,5 +518,10 @@ func configInitRun(opts *ConfigInitOptions) error {
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil
}

91
cmd/config/init_probe.go Normal file
View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// probeTimeout is the total wall-clock budget for the credential probe step
// (covering both TAT acquisition and the subsequent probe request).
const probeTimeout = 3 * time.Second
// runProbe runs a best-effort credential validation after config init has
// persisted the App ID and App Secret. It returns a non-nil error only for a
// deterministic credential-rejection signal; every other outcome returns nil
// so that valid configurations and transient/upstream noise never block the
// command.
//
// The function performs up to two HTTP calls in series, bounded by
// probeTimeout:
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
if err != nil {
// A typed error from FetchTAT is a deterministic credential rejection
// (classifyTATResponseCode). Propagate it so config init exits with the
// same envelope the rest of the CLI uses for bad credentials. Untyped
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
// silent and let the command succeed.
if errs.IsTyped(err) {
return err
}
return nil
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

View File

@@ -0,0 +1,288 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
probeReq *http.Request
probeBody string
}
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
f.probeCalls++
f.probeReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
f.probeBody = string(b)
}
if f.probeHandler == nil {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// fakeFactory builds a test Factory whose HttpClient is overridden to use
// the caller-supplied RoundTripper.
//
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
// guidance). The HttpClient is then swapped to our stub so we can drive
// exact HTTP responses for the probe. Config-dir isolation is set up via
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
// touch lands in a temp dir rather than the developer's real config.
//
// The returned buffer is the Factory's stderr. runProbe never writes to
// stderr (it propagates a typed error or stays silent), so every test asserts
// this buffer stays empty as an invariant.
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
}
return f, errBuf
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
// written to stderr. Used for every ambiguous (non-credential) outcome.
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err != nil {
t.Errorf("expected nil (silent), got error: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr output, got: %q", errBuf.String())
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
// rejection) → silent, exit 0.
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
for _, code := range []int{401, 403, 500} {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(code, `nope`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
rt := &fakeRT{
probeHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(500, `server error`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if rt.probeReq.Method != http.MethodPost {
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
}
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
t.Errorf("probe URL = %s", got)
}
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
t.Errorf("Authorization = %q, want Bearer t-ok", got)
}
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
t.Errorf("probe body missing from field: %s", rt.probeBody)
}
}
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
}
}
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
}
// A timeout is an ambiguous failure (context deadline → untyped), so it
// must stay silent and not block.
assertSilent(t, err, errBuf)
}

View File

@@ -241,6 +241,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return typedExit
}
// Partial-failure (batch / multi-status): the ok:false result envelope is
// already on stdout; set the exit code and write nothing to stderr.
var pfErr *output.PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)

View File

@@ -180,6 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {

View File

@@ -765,3 +765,22 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

View File

@@ -155,7 +155,30 @@ caller scripts.
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
belongs in a typed `*errs.XxxError`.
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
### Partial failure (batch / multi-status)
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
many items can finish in a third state, neither full success nor a single
error: some items succeeded and some failed. Its primary output is the
per-item result, so it does **not** belong in a `stderr` error envelope.
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
1. writes the full result to **stdout** as an `ok:false` envelope — the
summary and every per-item outcome (succeeded *and* failed) stay
machine-readable, exactly as a successful `Out(...)` would carry them,
but with `ok` honestly reporting failure; and
2. returns `*output.PartialFailureError`, a typed exit signal the
dispatcher maps to a non-zero exit code while writing nothing further
to `stderr`.
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
*result*, reported on stdout, that also failed. Consumers branch on
`ok == false` and then read `data.summary` / `data.items[]`.
## Consumers

View File

@@ -12,7 +12,8 @@ const (
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
)
// CategoryAuthentication subtypes

View File

@@ -61,8 +61,22 @@ type TypedError interface {
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Cause error `json:"-"`
Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"`
}
// InvalidParam is one structured validation diagnostic: the parameter that
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
// item (RFC 7807 §3.1 extension members).
//
// The wire key on ValidationError is "params" rather than "invalid_params"
// because the enclosing envelope already carries type:"validation", so the
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
// InvalidParam prefix because, at package level, the name must self-describe.
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
@@ -122,6 +136,11 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
return e
}
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
e.Params = append(e.Params, params...)
return e
}
func (e *ValidationError) WithCause(cause error) *ValidationError {
e.Cause = cause
return e

View File

@@ -558,6 +558,71 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
})
}
// TestValidationError_WithParams covers the structured-validation extension:
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
// and the wire shape nests {name, reason} under "params" (omitted when empty).
func TestValidationError_WithParams(t *testing.T) {
t.Run("appends and exposes fields", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
if len(e.Params) != 1 {
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
}
if e.Params[0].Name != "a.md" {
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
}
if e.Params[0].Reason != "duplicate" {
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
}
})
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
if returned != e {
t.Errorf("WithParams returned different pointer; want same as receiver")
}
e.WithParams(
errs.InvalidParam{Name: "b.md", Reason: "dup"},
errs.InvalidParam{Name: "c.md", Reason: "dup"},
)
if len(e.Params) != 3 {
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
}
})
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParam("--rel-path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
got := string(b)
for _, want := range []string{
`"type":"validation"`,
`"param":"--rel-path"`,
`"params":[{"name":"a.md","reason":"duplicate"}]`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %s", want, got)
}
}
})
t.Run("empty Params omitted from wire", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(b), `"params"`) {
t.Errorf("empty Params should be omitted from wire; got %s", b)
}
})
}
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
t.Run("WithMissingScopes clones input", func(t *testing.T) {
scopes := []string{"docx:document", "im:message:send"}

View File

@@ -4,9 +4,7 @@
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -166,42 +164,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
ep := core.ResolveEndpoints(acct.Brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": acct.AppID,
"app_secret": acct.AppSecret,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
}
return &TokenResult{Token: result.TenantAccessToken}, nil
return &TokenResult{Token: token}, nil
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/larksuite/cli/internal/core"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
// given credentials. It does not read configuration or keychain, so callers
// that already hold plaintext credentials (e.g. the post-`config init` probe)
// can validate them without a second keychain round-trip.
//
// A non-zero TAT response code means the server inspected the payload and
// rejected the credentials; FetchTAT returns the canonical typed error from
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// every token-resolving command) produces, so callers see one consistent
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
//
// The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
}
return result.TenantAccessToken, nil
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// stubRoundTripper lets us assert request shape and return canned responses.
type stubRoundTripper struct {
gotReq *http.Request
gotBody string
respCode int
respBody string
err error
}
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
s.gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
s.gotBody = string(b)
}
if s.err != nil {
return nil, s.err
}
return &http.Response{
StatusCode: s.respCode,
Body: io.NopCloser(strings.NewReader(s.respBody)),
Header: make(http.Header),
}, nil
}
func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{
respCode: 200,
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("request body missing credentials: %s", rt.gotBody)
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 10003")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
}
// 10014 ("app secret invalid") — the most common real-world rejection (real
// app_id + wrong secret) — is globally mapped in codemeta to
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
}
}
// Any non-zero body code is a deterministic server-side rejection, so it
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// caller still surfaces it rather than silently swallowing.
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 99999")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
}
}
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
// silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatalf("HTTP %d: expected error", code)
}
if errs.IsTyped(err) {
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
}
}
}
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
sentinel := errors.New("network down")
rt := &stubRoundTripper{err: sentinel}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error")
}
if errs.IsTyped(err) {
t.Errorf("transport error must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, sentinel) {
t.Errorf("error chain missing sentinel: %v", err)
}
}
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected parse error")
}
if errs.IsTyped(err) {
t.Errorf("parse error must be UNTYPED, got typed %T", err)
}
}
func TestFetchTAT_BrandRouting(t *testing.T) {
tests := []struct {
brand core.LarkBrand
wantURL string
}{
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)
}
if got := rt.gotReq.URL.String(); got != tc.wantURL {
t.Errorf("url = %s, want %s", got, tc.wantURL)
}
})
}
}
func TestFetchTAT_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
rt := &urlRewriteRT{base: srv.URL}
hc := &http.Client{Transport: rt}
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-canceled
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
if err == nil {
t.Fatal("expected error for canceled context")
}
if errs.IsTyped(err) {
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("error chain missing context.Canceled: %v", err)
}
}
// urlRewriteRT forwards requests to a fixed base URL (test server).
type urlRewriteRT struct{ base string }
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
newURL := r.base + req.URL.Path
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
if err != nil {
return nil, err
}
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}

View File

@@ -129,6 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
@@ -231,6 +232,22 @@ func ConfigHint(subtype errs.Subtype) string {
return ""
}
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
// layered on by the caller after BuildAPIError returns and overrides this.
func APIHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeConflict:
return "retry later and avoid concurrent duplicate requests on the same resource"
case errs.SubtypeCrossTenant:
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
}
return ""
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
identity := cc.Identity

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
// Each case traces to repo evidence (see codemeta_drive.go comments).
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 1061044: upload with a nonexistent parent folder token. The drive E2E
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
// producer via a nonexistent parent folder → referenced resource missing.
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -170,6 +170,28 @@ func ErrBare(code int) *ExitError {
return &ExitError{Code: code}
}
// PartialFailureError is the exit signal for a batch / multi-status command that
// has already written an ok:false result envelope to stdout. The per-item
// outcomes are the primary, machine-readable output and live on stdout, so the
// dispatcher sets only the exit code and writes nothing to stderr.
//
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
// so the predicate contract stays narrow, and from a typed *errs.XxxError
// (which owns the stderr error envelope): a partial failure is a result, not an
// error envelope.
type PartialFailureError struct {
Code int
}
func (e *PartialFailureError) Error() string {
return fmt.Sprintf("partial failure (exit %d)", e.Code)
}
// PartialFailure builds the partial-failure exit signal with the given code.
func PartialFailure(code int) *PartialFailureError {
return &PartialFailureError{Code: code}
}
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields

View File

@@ -61,6 +61,10 @@ func ExitCodeOf(err error) int {
if _, ok := errs.ProblemOf(err); ok {
return ExitCodeForCategory(errs.CategoryOf(err))
}
var pfErr *PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
var exitErr *ExitError
if errors.As(err, &exitErr) {
return exitErr.Code

View File

@@ -270,6 +270,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
Force: opts.Force,
}
if len(plan.ToUpdate) == 0 {
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {

View File

@@ -306,6 +306,39 @@ func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t
}
}
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
// to the typed errs.* taxonomy. On these paths, constructing a legacy
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/drive/",
}
// legacyOutputImportPath is the import path of the package that declares the
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
// name (default or alias) this path is bound to in each file, so an aliased
// import cannot bypass the check.
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
// output.ExitError / output.ErrDetail composite literals on migrated paths.
// forbidigo can ban identifiers but not composite literals, so this AST rule
// covers the gap left after a path is migrated to typed errs.* errors.
//
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
// Resolve the local name(s) bound to the legacy output import path. A file
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
// as bare unqualified idents.
localNames, dotImported := resolveLegacyOutputNames(file)
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
out = append(out, Violation{
Rule: "no_legacy_envelope_literal",
Action: ActionReject,
File: path,
Line: fset.Position(lit.Pos()).Line,
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
})
}
return true
})
return out
}
// isMigratedEnvelopePath reports whether path falls under any migrated path
// prefix in migratedEnvelopePaths.
func isMigratedEnvelopePath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
for _, prefix := range migratedEnvelopePaths {
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
return true
}
}
return false
}
// resolveLegacyOutputNames walks the file's import declarations and returns the
// set of local names bound to legacyOutputImportPath, plus whether the path was
// dot-imported. Default imports bind the package's own name ("output"); aliased
// imports bind the alias; dot-imports bind names into the file scope.
func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) {
names := make(map[string]struct{})
dotImported := false
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
p := strings.Trim(imp.Path.Value, "`\"")
if p != legacyOutputImportPath {
continue
}
switch {
case imp.Name == nil:
// Default import: local name is the package name "output".
names["output"] = struct{}{}
case imp.Name.Name == ".":
dotImported = true
case imp.Name.Name == "_":
// Blank import cannot reference the types; ignore.
default:
names[imp.Name.Name] = struct{}{}
}
}
return names, dotImported
}
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
// local names for the legacy output import, and — when the package was
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
if sel, ok := expr.(*ast.SelectorExpr); ok {
x, ok := sel.X.(*ast.Ident)
if !ok || sel.Sel == nil {
return "", false
}
if _, bound := localNames[x.Name]; !bound {
return "", false
}
return matchLegacyEnvelopeName(sel.Sel.Name)
}
if dotImported {
if ident, ok := expr.(*ast.Ident); ok {
return matchLegacyEnvelopeName(ident.Name)
}
}
return "", false
}
// matchLegacyEnvelopeName returns the name when it is one of the legacy
// envelope type names.
func matchLegacyEnvelopeName(name string) (string, bool) {
switch name {
case "ExitError", "ErrDetail":
return name, true
}
return "", false
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy
// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on
// migrated paths. Those helpers route failures through common.HandleApiResult /
// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and
// downgrade an already-typed network / auth boundary error into an API error.
// forbidigo's errs-typed-only ban does not see them because they are method
// calls, not output.Err* identifiers — this AST rule covers that gap.
//
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors.
//
// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper
// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it
// 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.
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return true
}
if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok {
out = append(out, Violation{
Rule: "no_legacy_runtime_api_call",
Action: ActionReject,
File: path,
Line: fset.Position(call.Pos()).Line,
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors",
})
}
return true
})
return out
}
// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's
// legacy auto-classifying API helper methods.
func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
switch name {
case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID":
return name, true
}
return "", false
}

View File

@@ -593,3 +593,287 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
t.Errorf("message must name the offending call: %s", v[0].Message)
}
}
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() *output.ErrDetail {
return &output.ErrDetail{Code: 7}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "ErrDetail") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return output.ErrBare(output.ExitAPI)
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("ErrBare call should pass, got: %+v", v)
}
}
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
// Same offending literal, but outside the migrated path set → not flagged.
src := `package other
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
}
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
if len(v) != 0 {
t.Errorf("_test.go file should be skipped, got: %+v", v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
// import of internal/output cannot bypass the rule: the qualifier is resolved
// from the import declaration, not matched against the literal string "output".
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
src := `package drive
import legacy "github.com/larksuite/cli/internal/output"
func boom() error {
return &legacy.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
// regression where resolving by import path accidentally drops the default
// (non-aliased) `output` case.
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
// a CallExpr, not a composite literal — even under an alias it must not fire.
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
src := `package drive
import legacy "github.com/larksuite/cli/internal/output"
func boom() error {
return legacy.ErrBare(legacy.ExitAPI)
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("ErrBare call should pass, got: %+v", v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
// the composite literal.
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
src := `package drive
import . "github.com/larksuite/cli/internal/output"
func boom() error {
return &ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
// selector on an unrelated package (not the legacy output import path) must not
// trigger a false positive.
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
src := `package drive
import "example.com/other/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("unrelated package selector must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "CallAPI") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
if len(v) != 0 {
t.Errorf("typed wrapper call must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
// RawAPI / DoAPI return the raw response for the caller to classify and do
// not emit a legacy envelope — they are not banned.
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
_, err := runtime.DoAPI(nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
if len(v) != 0 {
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package im
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
if len(v) != 0 {
t.Errorf("test files must be skipped, got: %+v", v)
}
}

View File

@@ -106,6 +106,8 @@ func ScanRepo(root string) ([]Violation, error) {
all = append(all, CheckNoRegistrar(rel, string(src))...)
all = append(all, CheckAdHocSubtype(rel, string(src))...)
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
// Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

@@ -0,0 +1,200 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"net/http"
"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"
)
func newCallAPITypedRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
return rt, reg
}
// TestCallAPITyped_HeaderOnlyLogID pins the P1 fix: when the server returns
// log_id only in the x-tt-logid response header (not in the JSON body), the
// typed error still carries it. The legacy runtime.CallAPI path (body-only)
// dropped it.
func TestCallAPITyped_HeaderOnlyLogID(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"hdr-log-123"},
},
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom"}, // no log_id in body
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if p.LogID != "hdr-log-123" {
t.Errorf("LogID = %q, want %q (lifted from x-tt-logid header)", p.LogID, "hdr-log-123")
}
}
// TestCallAPITyped_BodyLogID confirms body-level log_id still surfaces.
func TestCallAPITyped_BodyLogID(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "body-log-9"},
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.LogID != "body-log-9" {
t.Errorf("LogID = %q, want body-log-9", p.LogID)
}
}
// TestCallAPITyped_Success returns the data object on code 0, and does not leak
// the header log_id into the success payload (log_id surfacing is error-path
// only — success output stays identical to the legacy CallAPI).
func TestCallAPITyped_Success(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Headers: http.Header{
"Content-Type": []string{"application/json"},
"X-Tt-Logid": []string{"hdr-log-ok"},
},
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"token": "tok1"}},
})
data, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data["token"] != "tok1" {
t.Errorf("data[token] = %v, want tok1", data["token"])
}
if _, leaked := data["log_id"]; leaked {
t.Errorf("success data must not carry log_id, got: %v", data)
}
}
// TestAPIClassifyContext verifies the classify context is built from the
// runtime: Brand / AppID from config, Identity from the resolved caller, and
// LarkCmd from the running command path.
func TestAPIClassifyContext(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandLark, AppID: "cli_x"}
rt := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+upload"}, cfg, core.AsUser)
cc := rt.APIClassifyContext()
if cc.Brand != "lark" {
t.Errorf("Brand = %q, want lark", cc.Brand)
}
if cc.AppID != "cli_x" {
t.Errorf("AppID = %q, want cli_x", cc.AppID)
}
if cc.Identity != "user" {
t.Errorf("Identity = %q, want user", cc.Identity)
}
if cc.LarkCmd != "+upload" {
t.Errorf("LarkCmd = %q, want +upload", cc.LarkCmd)
}
bot := TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "+push"}, &core.CliConfig{Brand: core.BrandFeishu, AppID: "y"}, core.AsBot)
if got := bot.APIClassifyContext().Identity; got != "bot" {
t.Errorf("bot Identity = %q, want bot", got)
}
}
// TestCallAPITyped_NonJSON5xx pins that a non-JSON HTTP 5xx (e.g. a gateway 502
// text/html page) is a retryable network/server_error carrying the header
// log_id — not a mis-parsed internal/invalid_response.
func TestCallAPITyped_NonJSON5xx(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Status: 502,
Headers: http.Header{
"Content-Type": []string{"text/html"},
"X-Tt-Logid": []string{"hdr-502"},
},
RawBody: []byte("<html><body>502 Bad Gateway</body></html>"),
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError for non-JSON 5xx, got %T: %v", err, err)
}
if netErr.Subtype != errs.SubtypeNetworkServer {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkServer)
}
if !netErr.Retryable {
t.Error("5xx network error must be retryable")
}
if netErr.LogID != "hdr-502" {
t.Errorf("LogID = %q, want hdr-502 (from header)", netErr.LogID)
}
}
// TestCallAPITyped_5xxNoContentType pins that a 5xx with no Content-Type (which
// the body-only parse would mis-classify as invalid_response) is still a
// retryable network/server_error.
func TestCallAPITyped_5xxNoContentType(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
Status: 503,
Headers: http.Header{}, // explicitly no Content-Type header
RawBody: []byte("service unavailable"),
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
var netErr *errs.NetworkError
if !errors.As(err, &netErr) || netErr.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("expected retryable network/server_error, got %T: %v", err, err)
}
if !netErr.Retryable {
t.Error("5xx network error must be retryable")
}
}
// TestCallAPITyped_NonObjectJSON pins that a top-level non-object JSON body
// (e.g. "[]") is rejected as an invalid response, never a silent success ack.
func TestCallAPITyped_NonObjectJSON(t *testing.T) {
rt, reg := newCallAPITypedRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/x/y",
RawBody: []byte("[]"),
})
_, err := rt.CallAPITyped("POST", "/open-apis/x/y", nil, map[string]any{})
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError for non-object JSON, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -26,6 +26,7 @@ import (
"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/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
@@ -233,6 +234,133 @@ func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interfa
return HandleApiResult(result, err, "API call failed")
}
// CallAPITyped is the typed-only replacement for CallAPI: it performs the same
// SDK request (buildRequest → APIClient.DoAPI → DoSDKRequest, identical
// transport and query model to CallAPI) and returns the "data" object, but
// classifies failures into typed errs.* errors via errclass.BuildAPIError.
//
// A transport / auth error from the client boundary is already typed and passes
// through unchanged; a non-zero API response code is classified into a typed
// error carrying subtype / code / log_id. Unlike CallAPI it never emits a legacy
// output.ExitError envelope, and never downgrades a typed network/auth error.
//
// It lifts x-tt-logid from the response header (which the body-only parse drops)
// so log_id surfaces on the typed error even when the server returns it only in
// the header.
func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, typedOrInternal(err)
}
resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
if err != nil {
return nil, typedOrInternal(err)
}
return ctx.ClassifyAPIResponse(resp)
}
// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a
// typed errs.* error. It is the shared response classifier for typed API paths
// — used by CallAPITyped and by callers that drive the request themselves
// (e.g. file upload via DoAPI). It:
//
// 1. parses the JSON body; an unparseable body on an HTTP error status (a
// gateway 5xx text/html page, an empty body, a missing Content-Type) is
// classified by status — 5xx → retryable network/server_error, 404 →
// not_found, other 4xx → api error — not a misleading invalid-response
// internal error;
// 2. rejects a top-level non-object JSON ([], null, scalar) as an
// invalid-response internal error — never a silent success ack;
// 3. lifts x-tt-logid from the response header onto the typed error so log_id
// surfaces even when the body omits it;
// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any
// HTTP error status that parsed to code==0 as a status error.
//
// The success "data" object is returned untouched. On a non-zero API code the
// data is returned alongside the typed error, since the response can still
// carry fields a caller needs on failure (e.g. the file_token an overwrite
// returned, for token-stability handling).
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
logID, _ := logIDFromHeader(resp)["log_id"].(string)
result, parseErr := client.ParseJSONResponse(resp)
if parseErr != nil {
if resp.StatusCode >= 400 {
return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID)
}
return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody)
}
resultMap, ok := result.(map[string]interface{})
if !ok {
e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
if logID != "" {
e = e.WithLogID(logID)
}
return nil, e
}
if logID != "" {
if _, present := resultMap["log_id"]; !present {
resultMap["log_id"] = logID
}
}
out, _ := resultMap["data"].(map[string]interface{})
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
return out, apiErr
}
if resp.StatusCode >= 400 {
return out, httpStatusError(resp.StatusCode, resp.RawBody, logID)
}
return out, nil
}
// httpStatusError classifies an HTTP error status whose body is not a usable
// API envelope: 5xx → retryable network/server_error, 404 → not_found, other
// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis.
func httpStatusError(status int, rawBody []byte, logID string) error {
body := TruncateStr(strings.TrimSpace(string(rawBody)), 500)
if status >= 500 {
e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable()
if logID != "" {
e = e.WithLogID(logID)
}
return e
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status)
if logID != "" {
e = e.WithLogID(logID)
}
return e
}
// typedOrInternal passes an already-typed errs.* error through unchanged and
// lifts a still-untyped one to a typed internal error, so CallAPITyped never
// returns a bare/legacy error.
func typedOrInternal(err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.WrapInternal(err)
}
// APIClassifyContext builds the errclass.ClassifyContext for the running command
// from the runtime config and resolved identity.
func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext {
larkCmd := ""
if ctx.Cmd != nil {
larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ")
}
return errclass.ClassifyContext{
Brand: string(ctx.Config.Brand),
AppID: ctx.Config.AppID,
Identity: string(ctx.As()),
LarkCmd: larkCmd,
}
}
// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response.
// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options.
//
@@ -552,28 +680,47 @@ func (ctx *RuntimeContext) ValidatePath(path string) error {
// Out prints a success JSON envelope to stdout.
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
ctx.emit(data, meta, false)
ctx.emit(data, meta, false, true)
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
ctx.emit(data, meta, true)
ctx.emit(data, meta, true, true)
}
// emit is the shared success-path emitter. raw=true disables JSON HTML escaping so
// XML/HTML payloads (e.g. DocxXML bodies) are preserved verbatim; otherwise behavior
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
// and returns the partial-failure exit signal. Use it for batch operations
// where some items failed but the per-item outcomes are the primary output:
// the full result (summary + per-item statuses) stays machine-readable on
// stdout, the process exits non-zero, and nothing is written to stderr.
//
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
// envelope's ok field honestly reports failure instead of a misleading
// ok:true, and the exit signal is distinct from the predicate-only ErrBare.
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
ctx.emit(data, meta, false, false)
if ctx.outputErr != nil {
return ctx.outputErr
}
return output.PartialFailure(output.ExitAPI)
}
// emit is the shared stdout envelope emitter; ok sets the envelope's ok field
// (true for success, false for a partial-failure result). raw=true disables JSON
// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved
// verbatim; otherwise behavior
// is identical — content-safety scanning and race-safe first-error capture via
// outputErrOnce apply in both modes.
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw bool) {
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) {
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
if scanResult.Blocked {
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
return
}
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
@@ -1029,6 +1176,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")

View File

@@ -96,3 +96,76 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Fatal("did not expect completion func for --format when disabled")
}
}
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+read",
Description: "test read",
HasFormat: true,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+read"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
}
}
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+update",
Description: "test update",
HasFormat: true,
Flags: []Flag{
{Name: "json", Desc: "body JSON object", Required: true},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+update"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --json flag exists (from custom Flags), but should be the string type, not bool.
flag := cmd.Flags().Lookup("json")
if flag == nil {
t.Fatal("expected --json flag from custom Flags")
}
if flag.DefValue != "" {
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
}
}
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+write",
Description: "test write",
HasFormat: false,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+write"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --format is now registered for all shortcuts (regardless of HasFormat),
// so --json should also be present.
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered even when HasFormat is false")
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// TestOutPartialFailure pins the batch / multi-status contract: the result
// rides on stdout as an ok:false envelope (carrying the full payload), and the
// returned error is the typed partial-failure exit signal (ExitAPI), distinct
// from the predicate-only ErrBare.
func TestOutPartialFailure(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser)
payload := map[string]interface{}{
"summary": map[string]interface{}{"uploaded": 1, "failed": 1},
"items": []map[string]interface{}{
{"rel_path": "a.txt", "action": "uploaded"},
{"rel_path": "b.txt", "action": "failed", "error": "boom"},
},
}
err := rt.OutPartialFailure(payload, nil)
// 1) typed partial-failure exit signal
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
// 2) stdout envelope reports ok:false but still carries the full payload
// (both the succeeded and failed items) — consistent with a success Out().
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
}
if env.OK {
t.Errorf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
}
items, _ := env.Data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String())
}
}

View File

@@ -11,7 +11,7 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -152,13 +152,13 @@ var DriveAddComment = common.Shortcut{
if docRef.Kind == "sheet" {
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)").WithParam("--block-id")
}
if _, err := parseSheetCellRef(blockID); err != nil {
return err
}
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return output.ErrValidation("--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment and --selection-with-ellipsis are not applicable for sheet comments; use --block-id with <sheetId>!<cell> format")
}
return nil
}
@@ -167,20 +167,20 @@ var DriveAddComment = common.Shortcut{
return err
}
if runtime.Bool("full-comment") {
return output.ErrValidation("--full-comment is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return output.ErrValidation("--selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
}
return nil
}
selection := runtime.Str("selection-with-ellipsis")
blockID := strings.TrimSpace(runtime.Str("block-id"))
if strings.TrimSpace(selection) != "" && blockID != "" {
return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis and --block-id are mutually exclusive")
}
if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") {
return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment cannot be used with --selection-with-ellipsis or --block-id")
}
mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID)
@@ -188,7 +188,7 @@ var DriveAddComment = common.Shortcut{
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
}
return nil
@@ -398,7 +398,7 @@ var DriveAddComment = common.Shortcut{
}
blockID = match.AnchorBlockID
if strings.TrimSpace(blockID) == "" {
return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
}
selectedMatch = idx
fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID)
@@ -418,7 +418,7 @@ var DriveAddComment = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken))
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
requestPath,
nil,
@@ -473,7 +473,7 @@ func resolveCommentMode(explicitFullComment bool, selection, blockID string) com
func parseCommentDocRef(input, docType string) (commentDocRef, error) {
raw := strings.TrimSpace(input)
if raw == "" {
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractURLToken(raw, "/wiki/"); ok {
@@ -495,16 +495,16 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw)
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
}
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -519,7 +519,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments")
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
@@ -535,7 +535,7 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docRef.Token},
@@ -549,13 +549,13 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
return resolvedCommentTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType == "slides" && mode == commentModeFull {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but slide comments require --block-id <slide-block-type>!<xml-id>; --full-comment is not applicable", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but slide comments require --block-id <slide-block-type>!<xml-id>; --full-comment is not applicable", objType)
}
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
}
if objType == "sheet" {
// Sheet comments are handled via the sheet fast path in Execute.
@@ -592,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -663,16 +663,14 @@ func parseLocateDocResult(result map[string]interface{}) locateDocResult {
func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) {
if len(result.Matches) == 0 {
return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block")
return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "locate-doc did not find any matching block").WithParam("--selection-with-ellipsis")
}
if len(result.Matches) > 1 {
return locateDocMatch{}, 0, output.ErrWithHint(
output.ExitValidation,
"ambiguous_match",
fmt.Sprintf("locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)),
"narrow --selection-with-ellipsis until only one block matches",
)
return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)).
WithHint("narrow --selection-with-ellipsis until only one block matches").
WithParam("--selection-with-ellipsis")
}
return result.Matches[0], 1, nil
@@ -705,15 +703,15 @@ func summarizeLocateMatch(match locateDocMatch) string {
func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return nil, output.ErrValidation("--content cannot be empty")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content")
}
var inputs []commentReplyElementInput
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
}
if len(inputs) == 0 {
return nil, output.ErrValidation("--content must contain at least one reply element")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
}
replyElements := make([]map[string]interface{}, 0, len(inputs))
@@ -724,7 +722,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
switch elementType {
case "text":
if strings.TrimSpace(input.Text) == "" {
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=text requires non-empty text", index).WithParam("--content")
}
// Measure the raw rune count of the user input — that is what
// the server actually counts. byte width and post-escape form
@@ -734,13 +732,11 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
runes := utf8.RuneCountInString(input.Text)
totalRunes += runes
if totalRunes > maxCommentTotalRunes {
return nil, output.ErrWithHint(
output.ExitValidation,
"text_too_long",
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
totalRunes, index, runes, maxCommentTotalRunes),
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
totalRunes, index, runes, maxCommentTotalRunes).
WithHint("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes).
WithParam("--content")
}
// Escape '<' and '>' so the rendered comment displays them as
// literal characters instead of being interpreted as markup
@@ -754,7 +750,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
case "mention_user":
mentionUser := firstNonEmptyString(input.MentionUser, input.Text)
if mentionUser == "" {
return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=mention_user requires text or mention_user", index).WithParam("--content")
}
replyElements = append(replyElements, map[string]interface{}{
"type": "mention_user",
@@ -763,14 +759,14 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
case "link":
link := firstNonEmptyString(input.Link, input.Text)
if link == "" {
return nil, output.ErrValidation("--content element #%d type=link requires text or link", index)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=link requires text or link", index).WithParam("--content")
}
replyElements = append(replyElements, map[string]interface{}{
"type": "link",
"link": link,
})
default:
return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type).WithParam("--content")
}
}
@@ -827,17 +823,17 @@ func anchorBlockIDForDryRun(blockID string) string {
func parseSlidesBlockRef(blockID string) (string, string, error) {
blockID = strings.TrimSpace(blockID)
if blockID == "" {
return "", "", output.ErrValidation("slide comments require --block-id in <slide-block-type>!<xml-id> format")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide comments require --block-id in <slide-block-type>!<xml-id> format").WithParam("--block-id")
}
parts := strings.SplitN(blockID, "!", 2)
if len(parts) != 2 {
return "", "", output.ErrValidation("slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID).WithParam("--block-id")
}
parsedType := strings.TrimSpace(parts[0])
parsedID := strings.TrimSpace(parts[1])
if parsedType == "" || parsedID == "" {
return "", "", output.ErrValidation("slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID).WithParam("--block-id")
}
return parsedID, parsedType, nil
}
@@ -865,7 +861,7 @@ func firstPresentValue(m map[string]interface{}, keys ...string) interface{} {
func parseSheetCellRef(input string) (*sheetAnchor, error) {
parts := strings.SplitN(input, "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input).WithParam("--block-id")
}
sheetID := parts[0]
cell := strings.TrimSpace(parts[1])
@@ -876,7 +872,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
i++
}
if i == 0 || i >= len(cell) {
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id cell reference %q is invalid (expected e.g. D6)", cell).WithParam("--block-id")
}
colStr := strings.ToUpper(cell[:i])
rowStr := cell[i:]
@@ -890,7 +886,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
row, err := strconv.Atoi(rowStr)
if err != nil || row < 1 {
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id row %q is invalid (must be >= 1)", rowStr).WithParam("--block-id")
}
row-- // convert to 0-based
@@ -898,7 +894,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
}
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
@@ -917,11 +913,11 @@ func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken strin
metas := common.GetSlice(data, "metas")
if len(metas) == 0 {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
}
meta, ok := metas[0].(map[string]interface{})
if !ok {
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
}
return common.GetString(meta, "title"), nil
}
@@ -936,23 +932,19 @@ func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken
return title, extension, nil
}
if strings.TrimSpace(title) == "" {
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title",
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title").
WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()).
WithParam("--doc")
}
extensionLabel := extension
if extensionLabel == "" {
extensionLabel = "no extension"
}
return "", "", output.ErrWithHint(
output.ExitValidation,
"unsupported_file_comment_type",
fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel),
"file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(),
)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel).
WithHint("file comments currently support full comments only for these extensions: " + supportedFileCommentExtensionsText()).
WithParam("--doc")
}
func fileCommentExtension(title string) string {
@@ -993,9 +985,9 @@ func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
return nil
}
if resolvedObjType != "" {
return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
}
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file comments only support full comments; omit --block-id and --selection-with-ellipsis")
}
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
@@ -1006,7 +998,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)").WithParam("--block-id")
}
anchor, err := parseSheetCellRef(blockID)
if err != nil {
@@ -1019,7 +1011,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
fmt.Fprintf(runtime.IO().ErrOut, "Creating sheet comment in %s (sheet=%s, col=%d, row=%d)\n",
common.MaskToken(docRef.Token), anchor.SheetID, anchor.Col, anchor.Row)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
@@ -1054,7 +1046,7 @@ func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTa
fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
@@ -1097,7 +1089,7 @@ func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef)
fmt.Fprintf(runtime.IO().ErrOut, "Creating slide block comment in %s (block_id=%s, slide_block_type=%s)\n",
common.MaskToken(docRef.Token), blockID, slideBlockType)
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}

View File

@@ -9,11 +9,32 @@ 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"
)
// assertContentValidationHint asserts err is a typed *errs.ValidationError
// carrying SubtypeInvalidArgument, Param "--content", and a Hint containing
// the given substring. The over-cap message now flows through a typed
// ValidationError instead of the legacy *output.ExitError.Detail shape.
func assertContentValidationHint(t *testing.T, err error, wantHint string) {
t.Helper()
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if valErr.Param != "--content" {
t.Errorf("Param = %q, want %q", valErr.Param, "--content")
}
if !strings.Contains(valErr.Hint, wantHint) {
t.Errorf("expected hint substring %q, got %q", wantHint, valErr.Hint)
}
}
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
t.Helper()
@@ -421,14 +442,8 @@ func TestParseCommentReplyElementsTextLength(t *testing.T) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
if tt.wantHint != "" {
// Hint lives on ExitError.Detail.Hint, not err.Error().
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
}
// Hint lives on the typed ValidationError, not err.Error().
assertContentValidationHint(t, err, tt.wantHint)
}
return
}
@@ -458,11 +473,11 @@ func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
if err == nil {
t.Fatal("expected over-cap error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
hint := exitErr.Detail.Hint
hint := valErr.Hint
// The hint must explicitly call out that splitting does NOT help.
if !strings.Contains(hint, "does NOT help") {

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,7 +44,7 @@ var permApplyURLMarkers = []struct {
func resolvePermApplyTarget(raw, explicitType string) (token, docType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", output.ErrValidation("--token is required")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
if strings.Contains(raw, "://") {
@@ -58,10 +58,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
}
}
if token == "" {
return "", "", output.ErrValidation(
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
)
).WithParam("--token")
}
} else {
token = raw
@@ -71,10 +71,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
docType = explicitType
}
if docType == "" {
return "", "", output.ErrValidation(
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(permApplyTypes, ", "),
)
).WithParam("--type")
}
return token, docType, nil
}
@@ -125,7 +125,7 @@ var DriveApplyPermission = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Requesting %s access on %s %s...\n",
runtime.Str("perm"), docType, common.MaskToken(token))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/apply", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -72,7 +72,7 @@ var DriveCreateFolder = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target)
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
@@ -84,7 +84,7 @@ var DriveCreateFolder = common.Shortcut{
folderToken := common.GetString(data, "token")
if folderToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive create_folder succeeded but returned no folder token (data.token)")
}
out := map[string]interface{}{
"created": true,
@@ -108,14 +108,14 @@ var DriveCreateFolder = common.Shortcut{
func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error {
if spec.Name == "" {
return output.ErrValidation("--name must not be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty").WithParam("--name")
}
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 256 bytes (got %d)", nameBytes).WithParam("--name")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
return nil

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -84,7 +84,7 @@ var DriveCreateShortcut = common.Shortcut{
common.MaskToken(spec.FolderToken),
)
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/files/create_shortcut",
nil,
@@ -118,19 +118,19 @@ var DriveCreateShortcut = common.Shortcut{
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first").WithParam("--type")
}
if spec.FileType == "folder" {
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: folder. The create_shortcut API only supports Drive files, not folders").WithParam("--type")
}
if !driveCreateShortcutAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType).WithParam("--type")
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -312,24 +313,24 @@ func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
t.Fatal("expected API error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T (%v)", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
if output.ExitCodeOf(err) != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitAPI)
}
if exitErr.Detail.Type != tt.wantType {
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if string(apiErr.Subtype) != tt.wantType {
t.Fatalf("subtype = %q, want %q", apiErr.Subtype, tt.wantType)
}
if exitErr.Detail.Code != tt.code {
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
if apiErr.Code != tt.code {
t.Fatalf("code = %d, want %d", apiErr.Code, tt.code)
}
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
if !strings.Contains(apiErr.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", apiErr.Message, tt.wantMsgPart)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
if !strings.Contains(apiErr.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", apiErr.Hint, tt.wantHint)
}
})
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -81,7 +81,7 @@ var DriveDelete = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
@@ -94,7 +94,7 @@ var DriveDelete = common.Shortcut{
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
@@ -136,13 +136,13 @@ var DriveDelete = common.Shortcut{
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported").WithParam("--type")
}
if !driveDeleteAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType).WithParam("--type")
}
return nil
}

View File

@@ -10,8 +10,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,7 +44,7 @@ var DriveDownload = common.Shortcut{
overwrite := runtime.Bool("overwrite")
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if outputPath == "" {
@@ -53,10 +53,10 @@ var DriveDownload = common.Shortcut{
// Early path validation + overwrite check
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken))
@@ -66,7 +66,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapDriveNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()
@@ -75,7 +75,7 @@ var DriveDownload = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)

View File

@@ -17,9 +17,9 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
const (
@@ -823,64 +823,37 @@ func registerDownload(reg *httpmock.Registry, fileToken, body string) {
func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) {
t.Helper()
if err == nil {
t.Fatal("expected duplicate_remote_path error, got nil")
t.Fatal("expected duplicate rel_path validation error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
if validationErr.Hint == "" {
t.Fatal("duplicate validation error should carry a recovery hint so AI consumers know the next action")
}
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
if len(validationErr.Params) == 0 {
t.Fatal("duplicate validation error should carry at least one param")
}
duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath)
if !ok {
t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"])
}
if len(duplicates) == 0 {
t.Fatal("duplicate detail should include at least one rel_path group")
}
if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey {
t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap)
}
var matched bool
for _, duplicate := range duplicates {
if duplicate.RelPath != relPath {
continue
}
matched = true
if len(duplicate.Entries) != len(tokens) {
t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath)
}
for i, token := range tokens {
if duplicate.Entries[i].FileToken != token {
t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token)
}
if duplicate.Entries[i].Type == "" {
t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath)
}
var matched *errs.InvalidParam
for i := range validationErr.Params {
if validationErr.Params[i].Name == relPath {
matched = &validationErr.Params[i]
break
}
}
if !matched {
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
if matched == nil {
t.Fatalf("duplicate params missing rel_path group %q: %#v", relPath, validationErr.Params)
}
raw, marshalErr := json.Marshal(exitErr.Detail.Detail)
if marshalErr != nil {
t.Fatalf("marshal detail: %v", marshalErr)
}
text := string(raw)
if !strings.Contains(text, relPath) {
t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text)
if matched.Reason == "" {
t.Fatalf("duplicate param for rel_path %q missing reason", relPath)
}
for _, token := range tokens {
if !strings.Contains(text, token) {
t.Fatalf("duplicate detail missing token %q: %s", token, text)
if !strings.Contains(matched.Reason, token) {
t.Fatalf("duplicate param reason missing token %q: %s", token, matched.Reason)
}
}
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// wrapDriveNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving its subtype / code / log_id from the runtime boundary),
// and only wraps a raw, unclassified error as a transport-level network error.
func wrapDriveNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → "cannot read file: ..."
func driveInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}
// driveSaveError maps a FileIO.Save error to a typed error. Path validation
// failures are validation errors (exit code 2); mkdir / write failures are
// internal file-I/O errors (exit code 5).
func driveSaveError(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
}
}
// appendDriveExportRecoveryHint attaches a recovery hint to err while preserving
// its original classification (typed subtype/code or legacy detail), only falling
// back to a typed internal error when err is unclassified.
func appendDriveExportRecoveryHint(err error, hint string) error {
if err == nil {
return nil
}
// An already-typed error keeps its own category/subtype/code/log_id
// (per ERROR_CONTRACT.md "propagate typed errors unchanged"); we only
// append the recovery hint. p points at the embedded Problem, so the
// mutation is reflected in the returned err.
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
// Legacy *output.ExitError fallback: preserve the original error's
// class/exit code by appending the hint in place rather than downgrading
// to api/server_error.
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
exitErr.Detail.Hint = exitErr.Detail.Hint + "\n" + hint
} else {
exitErr.Detail.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
}

View File

@@ -5,13 +5,12 @@ package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -107,7 +106,7 @@ var DriveExport = common.Shortcut{
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.DoAPIJSONWithLogID(
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
@@ -122,11 +121,11 @@ var DriveExport = common.Shortcut{
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
@@ -207,11 +206,7 @@ var DriveExport = common.Shortcut{
status.FileToken,
recoveryCommand,
)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", err.Error(), hint)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
@@ -225,7 +220,7 @@ var DriveExport = common.Shortcut{
if msg == "" {
msg = status.StatusLabel()
}
return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
@@ -238,14 +233,7 @@ var DriveExport = common.Shortcut{
ticket,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastPollErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return output.ErrWithHint(output.ExitAPI, "api_error", lastPollErr.Error(), hint)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false

View File

@@ -15,9 +15,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/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -127,48 +127,48 @@ func (s driveExportStatus) StatusLabel() string {
// backend request is sent.
func validateDriveExportSpec(spec driveExportSpec) error {
if err := validate.ResourceName(spec.Token, "--token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
switch spec.DocType {
case "doc", "docx", "sheet", "bitable", "slides":
default:
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
}
switch spec.FileExtension {
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
default:
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
}
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
}
if spec.FileExtension == "base" && spec.DocType != "bitable" {
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
}
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
}
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
return output.ErrValidation("--doc-type slides only supports --file-extension pptx or pdf")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
}
if strings.TrimSpace(spec.SubID) != "" {
if spec.FileExtension != "csv" || (spec.DocType != "sheet" && spec.DocType != "bitable") {
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
}
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
}
}
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
}
return nil
@@ -186,14 +186,14 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
body["sub_id"] = spec.SubID
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return "", err
}
ticket := common.GetString(data, "ticket")
if ticket == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "export task created but ticket is missing")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket is missing")
}
return ticket, nil
}
@@ -201,7 +201,7 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
// getDriveExportStatus fetches the current backend state for a previously
// created export task.
func getDriveExportStatus(runtime *common.RuntimeContext, token, ticket string) (driveExportStatus, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
@@ -251,12 +251,12 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo
// Overwrite check via FileIO.Stat
if !overwrite {
if _, statErr := fio.Stat(target); statErr == nil {
return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", target)
}
}
if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
return "", driveSaveError(err)
}
resolvedPath, _ := fio.ResolvePath(target)
if resolvedPath == "" {
@@ -269,7 +269,7 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo
// file name, and returns metadata about the saved file.
func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outputDir, preferredName string, overwrite bool) (map[string]interface{}, error) {
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
@@ -277,10 +277,24 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
return nil, wrapDriveNetworkErr(err, "download failed: %s", err)
}
if apiResp.StatusCode >= 400 {
return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
subtype := errs.SubtypeNetworkTransport
if apiResp.StatusCode >= 500 {
subtype = errs.SubtypeNetworkServer
}
e := errs.NewNetworkError(subtype, "download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)).WithCode(apiResp.StatusCode)
// Mirror internal/client streamLogID: fall back to the request-id header
// when log-id is absent so the diagnostic ID is still populated.
logID := strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(apiResp.Header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID != "" {
e = e.WithLogID(logID)
}
return nil, e
}
fileName := strings.TrimSpace(preferredName)

View File

@@ -6,7 +6,7 @@ package drive
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +30,7 @@ var DriveExportDownload = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
return nil
},

View File

@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -360,12 +361,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
t.Fatal("expected error for missing document object, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(intErr.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", intErr.Message)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
}
}
@@ -396,12 +403,18 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
t.Fatal("expected error for missing document.content, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(intErr.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", intErr.Message)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
}
}
@@ -688,21 +701,25 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
t.Fatal("expected download recovery error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
// The download itself succeeds; the local "file already exists" failure is a
// validation error. The recovery-hint wrapper must preserve that typed class
// (exit 2) instead of downgrading it to api/server_error (exit 1), per
// ERROR_CONTRACT.md "propagate typed errors unchanged".
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError (preserved class), got %T", err)
}
if !strings.Contains(exitErr.Detail.Message, "already exists") {
t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message)
if !strings.Contains(valErr.Message, "already exists") {
t.Fatalf("message missing overwrite guidance: %q", valErr.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") {
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
if !strings.Contains(valErr.Hint, "ticket=tk_ready") {
t.Fatalf("hint missing ticket: %q", valErr.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "file_token=box_ready") {
t.Fatalf("hint missing file token: %q", exitErr.Detail.Hint)
if !strings.Contains(valErr.Hint, "file_token=box_ready") {
t.Fatalf("hint missing file token: %q", valErr.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
if !strings.Contains(valErr.Hint, `lark-cli drive +export-download --file-token "box_ready" --file-name "report.pdf"`) {
t.Fatalf("hint missing recovery command: %q", valErr.Hint)
}
}
@@ -856,18 +873,26 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
// The poll error is now a typed *errs.APIError (runtime.CallAPITyped).
// The recovery-hint wrapper must preserve that error's class and exit code
// (NOT downgrade it) and only append the recovery hint to the Problem in place.
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T (%v)", err, err)
}
if !strings.Contains(exitErr.Detail.Message, "temporary backend failure") {
t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message)
// Lark code 999 is unknown to the classifier, so it maps to CategoryAPI →
// ExitAPI — the wrapper must keep that, not force a different exit code.
if output.ExitCodeOf(err) != output.ExitAPI {
t.Fatalf("exit code = %d, want preserved %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
}
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") {
t.Fatalf("hint missing ticket: %q", exitErr.Detail.Hint)
if !strings.Contains(p.Message, "temporary backend failure") {
t.Fatalf("message missing last poll error: %q", p.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "ticket=tk_poll_fail") {
t.Fatalf("hint missing ticket: %q", p.Hint)
}
if !strings.Contains(p.Hint, "lark-cli drive +task_result --scenario export --ticket tk_poll_fail --file-token docx123") {
t.Fatalf("hint missing recovery command: %q", p.Hint)
}
}

View File

@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -161,10 +161,10 @@ func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64,
// and format-specific size limits before planning the upload path.
info, err := fio.Stat(spec.FilePath)
if err != nil {
return 0, common.WrapInputStatError(err)
return 0, driveInputStatError(err)
}
if !info.Mode().IsRegular() {
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file")
}
if err = validateDriveImportFileSize(spec.FilePath, spec.DocType, info.Size()); err != nil {
return 0, err

View File

@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -95,7 +95,7 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{}
func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) {
importInfo, err := runtime.FileIO().Stat(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", driveInputStatError(err)
}
fileSize := importInfo.Size()
@@ -142,7 +142,7 @@ func buildImportMediaExtra(filePath, docType string) (string, error) {
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
})
if err != nil {
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", err)
return "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err)
}
return string(extraBytes), nil
}
@@ -178,20 +178,20 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
if ext == "csv" {
// CSV is the only source format whose limit depends on the target type.
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"file %s exceeds %s import limit for .csv when importing as %s",
common.FormatSize(fileSize),
common.FormatSize(limit),
docType,
)
).WithParam("--file")
}
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"file %s exceeds %s import limit for .%s",
common.FormatSize(fileSize),
common.FormatSize(limit),
ext,
)
).WithParam("--file")
}
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
@@ -199,18 +199,18 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
func validateDriveImportSpec(spec driveImportSpec) error {
ext := spec.FileExtension()
if ext == "" {
return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx, .pptx)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
}
switch spec.DocType {
case "docx", "sheet", "bitable", "slides":
default:
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType).WithParam("--type")
}
supportedTypes, ok := driveImportExtToDocTypes[ext]
if !ok {
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
}
typeAllowed := false
@@ -236,21 +236,21 @@ func validateDriveImportSpec(spec driveImportSpec) error {
default:
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
}
return output.ErrValidation("file type mismatch: %s", hint)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file type mismatch: %s", hint)
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if strings.TrimSpace(spec.TargetToken) != "" {
if spec.DocType != "bitable" {
return output.ErrValidation("--target-token is only supported when --type is bitable")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-token is only supported when --type is bitable").WithParam("--target-token")
}
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--target-token")
}
}
@@ -308,14 +308,14 @@ func driveImportTaskResultCommand(ticket string) string {
// createDriveImportTask creates the server-side import task after the media
// upload has produced a reusable file token.
func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec, fileToken string) (string, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
if err != nil {
return "", err
}
ticket := common.GetString(data, "ticket")
if ticket == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "no ticket returned from import_tasks")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "no ticket returned from import_tasks")
}
return ticket, nil
}
@@ -323,10 +323,10 @@ func createDriveImportTask(runtime *common.RuntimeContext, spec driveImportSpec,
// getDriveImportStatus fetches the current state of an import task by ticket.
func getDriveImportStatus(runtime *common.RuntimeContext, ticket string) (driveImportStatus, error) {
if err := validate.ResourceName(ticket, "--ticket"); err != nil {
return driveImportStatus{}, output.ErrValidation("%s", err)
return driveImportStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/drive/v1/import_tasks/%s", validate.EncodePathSegment(ticket)),
nil,
@@ -391,7 +391,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
if msg == "" {
msg = status.StatusLabel()
}
return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg)
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
}
}
if !hadSuccessfulPoll && lastErr != nil {

View File

@@ -9,7 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,18 +37,18 @@ var DriveInspect = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return output.ErrValidation("--url cannot be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
}
_, ok := common.ParseResourceURL(raw)
if !ok {
// Not a recognized URL pattern.
if strings.Contains(raw, "://") {
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
}
// Bare token: --type is required.
if strings.TrimSpace(runtime.Str("type")) == "" {
return output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
}
}
return nil
@@ -111,7 +111,7 @@ var DriveInspect = common.Shortcut{
// Step 2: If type is "wiki", unwrap via get_node API.
if docType == "wiki" {
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
@@ -128,7 +128,7 @@ var DriveInspect = common.Shortcut{
nodeToken := common.GetString(node, "node_token")
if objType == "" || objToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
}
wikiNode = map[string]interface{}{

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"net/http"
@@ -17,6 +18,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -1338,9 +1340,20 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("Validate() error = %T %v, want *errs.ValidationError", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(verr.Error(), "mutually exclusive") {
t.Fatalf("Validate() error = %v, want mutually exclusive error", err)
}
// Multi-flag conflict carries no single Param.
if verr.Param != "" {
t.Fatalf("Param = %q, want empty for multi-flag conflict", verr.Param)
}
}
func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
@@ -1361,9 +1374,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--wiki-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty wiki-token error", err)
}
assertDriveValidationParam(t, err, "--wiki-token", "--wiki-token cannot be empty")
}
func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
@@ -1384,9 +1395,7 @@ func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty file-token error", err)
}
assertDriveValidationParam(t, err, "--file-token", "--file-token cannot be empty")
}
func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
@@ -1407,8 +1416,25 @@ func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
err := DriveUpload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--folder-token cannot be empty") {
t.Fatalf("Validate() error = %v, want empty folder-token error", err)
assertDriveValidationParam(t, err, "--folder-token", "--folder-token cannot be empty")
}
// assertDriveValidationParam asserts err is a typed *errs.ValidationError with
// SubtypeInvalidArgument, the given Param, and a message containing wantMsg.
func assertDriveValidationParam(t *testing.T, err error, wantParam, wantMsg string) {
t.Helper()
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", verr.Subtype, errs.SubtypeInvalidArgument)
}
if verr.Param != wantParam {
t.Fatalf("Param = %q, want %q", verr.Param, wantParam)
}
if !strings.Contains(verr.Error(), wantMsg) {
t.Fatalf("error = %q, want substring %q", verr.Error(), wantMsg)
}
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -74,14 +74,14 @@ var DriveMove = common.Shortcut{
return err
}
if rootToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "get root folder token failed, root folder is empty")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "get root folder token failed, root folder is empty")
}
spec.FolderToken = rootToken
}
fmt.Fprintf(runtime.IO().ErrOut, "Moving %s %s to folder %s...\n", spec.FileType, common.MaskToken(spec.FileToken), common.MaskToken(spec.FolderToken))
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/files/%s/move", validate.EncodePathSegment(spec.FileToken)),
nil,
@@ -95,7 +95,7 @@ var DriveMove = common.Shortcut{
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "move folder returned no task_id")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "move folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder move is async, polling task %s...\n", taskID)
@@ -139,14 +139,14 @@ var DriveMove = common.Shortcut{
// getRootFolderToken resolves the caller's Drive root folder token so other
// commands can safely use it as a default destination.
func getRootFolderToken(ctx context.Context, runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
if err != nil {
return "", err
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root_folder/meta returned no token")
}
return token, nil

View File

@@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,15 +47,15 @@ func (s driveMoveSpec) RequestBody() map[string]interface{} {
func validateDriveMoveSpec(spec driveMoveSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if strings.TrimSpace(spec.FolderToken) != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if !driveMoveAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType).WithParam("--type")
}
return nil
}
@@ -109,10 +109,10 @@ func driveTaskCheckParams(taskID string) map[string]interface{} {
// folder move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, output.ErrValidation("%s", err)
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
if err != nil {
return driveTaskCheckStatus{}, err
}
@@ -163,7 +163,7 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
}
}

View File

@@ -15,8 +15,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"
)
@@ -88,26 +88,26 @@ var DrivePull = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-local requires --yes (high-risk: deletes local files absent from Drive)").WithParam("--yes")
}
return nil
},
@@ -143,18 +143,18 @@ var DrivePull = common.Shortcut{
// remove the wrong files outside cwd.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
// rootRelToCwd is the localDir form FileIO.Save accepts (it
// rejects absolute paths). For cwd itself it becomes ".", which
// joins cleanly with the rel_paths returned by the lister.
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir")
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
@@ -174,7 +174,7 @@ var DrivePull = common.Shortcut{
// treated as orphaned.
remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
var downloaded, skipped, failed, deletedLocal int
@@ -293,26 +293,25 @@ var DrivePull = common.Shortcut{
// Item-level failures (download error, dir/file conflict, delete
// error) must surface as a non-zero exit so AI / script callers
// don't have to reach into summary.failed to detect a partial
// sync. The same structured payload rides along in error.detail
// so forensics aren't lost. When --delete-local was skipped
// because of an earlier download failure, callers see
// deleted_local=0 plus the download failure that aborted it,
// which is what makes the partial state self-explanatory.
// sync. On any failure the structured payload (summary + items +
// a "note" carrying the human guidance) is written to stdout as an
// ok:false result via OutPartialFailure, which also sets the exit
// code, so the per-item context is never lost. When --delete-local
// was skipped because
// of an earlier download failure, callers see deleted_local=0
// plus the download failure that aborted it, which is what makes
// the partial state self-explanatory.
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
note := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
if deleteLocal && downloadFailed > 0 {
msg += " (--delete-local was skipped because the download pass had failures)"
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
note += " (--delete-local was skipped because the download pass had failures)"
}
payload["note"] = note
}
if failed > 0 {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
@@ -326,14 +325,14 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
return wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return driveSaveError(err)
}
if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err)
@@ -350,10 +349,10 @@ func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime
}
resolved, err := runtime.FileIO().ResolvePath(target)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err)
}
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "cannot preserve remote modified_time on local file: %s", err).WithCause(err)
}
return nil
}
@@ -437,7 +436,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime}
remotePaths[rel] = struct{}{}
default:
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remotePaths, nil
@@ -467,7 +466,7 @@ func drivePullWalkLocal(root string) ([]string, error) {
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
}
return paths, nil
}

View File

@@ -478,9 +478,9 @@ func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
// already a directory locally. SafeOutputPath would refuse to overwrite
// the directory at write time, but if --if-exists=skip silently swallows
// the collision the caller sees "skipped" and assumes the mirror is
// in sync. The fix surfaces it as a structured `partial_failure`
// ExitError (non-zero exit + items[] in error.detail) under both skip
// and overwrite policies so callers can react via exit code.
// in sync. The fix surfaces it as a partial-failure (ok:false items[] payload
// on stdout + non-zero exit) under both skip and overwrite policies so callers
// can react via exit code.
func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
for _, policy := range []string{"overwrite", "skip"} {
t.Run(policy, func(t *testing.T) {
@@ -515,8 +515,8 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
"--if-exists", policy,
"--as", "bot",
}, f, stdout)
detail := assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullDetail(t, detail)
assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("[%s] summary.failed = %v, want 1", policy, got)
}
@@ -529,9 +529,6 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
if msg, _ := items[0]["error"].(string); !strings.Contains(msg, "is a directory") {
t.Errorf("[%s] error message should mention the directory conflict, got: %q", policy, msg)
}
if stdout.Len() != 0 {
t.Errorf("[%s] stdout should be empty on partial_failure, got: %s", policy, stdout.String())
}
})
}
}
@@ -900,8 +897,8 @@ func TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder(t *testing
// TestDrivePullDeleteLocalCountsFailureInSummary pins the contract that
// a failed delete shows up in summary.failed (not just in items[]) AND
// surfaces as a partial_failure ExitError so callers can detect the
// half-synced state via exit code. Before the fix, the delete_failed
// surfaces as a non-zero exit (partial-failure signal) so callers can detect
// the half-synced state via exit code. Before the fix, the delete_failed
// branches appended an item but left `failed` at zero AND returned nil,
// so the JSON envelope reported `ok=true`+`exit=0` even when the mirror
// was incomplete. Setup forces os.Remove to fail by making the file's
@@ -947,8 +944,8 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
"--yes",
"--as", "bot",
}, f, stdout)
detail := assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullDetail(t, detail)
assertDrivePullPartialFailure(t, err)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1 (delete_failed must increment failed)", got)
}
@@ -958,15 +955,12 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
if len(items) != 1 || items[0]["action"] != "delete_failed" {
t.Errorf("expected one items[] entry with action=delete_failed, got: %#v", items)
}
if stdout.Len() != 0 {
t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String())
}
}
// TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero pins the
// gating contract for --delete-local: when the download pass produced
// any failure, the delete walk MUST be skipped entirely and the command
// MUST exit non-zero with type=partial_failure. The half-synced state
// MUST exit non-zero via the partial-failure signal. The half-synced state
// where some Drive files are missing locally AND some local-only files
// have been removed is never observable.
func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
@@ -1014,12 +1008,12 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
"--yes",
"--as", "bot",
}, f, stdout)
exitErr := assertDrivePullPartialFailure(t, err)
if !strings.Contains(exitErr.Detail.Message, "--delete-local was skipped") {
t.Errorf("expected message to mention --delete-local skip, got: %q", exitErr.Detail.Message)
assertDrivePullPartialFailure(t, err)
if note := drivePullStdoutNote(t, stdout.Bytes()); !strings.Contains(note, "--delete-local was skipped") {
t.Errorf("expected note to mention --delete-local skip, got: %q", note)
}
summary, items := splitDrivePullDetail(t, exitErr)
summary, items := splitDrivePullStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Errorf("summary.failed = %v, want 1", got)
}
@@ -1036,9 +1030,6 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
if _, statErr := os.Stat(stale); statErr != nil {
t.Fatalf("stale.txt must survive when --delete-local is skipped after a download failure; stat err=%v", statErr)
}
if stdout.Len() != 0 {
t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String())
}
}
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
@@ -1343,49 +1334,60 @@ func mustReadFile(t *testing.T, path, want string) {
}
}
// assertDrivePullPartialFailure asserts that err is the structured
// partial_failure ExitError +pull returns when any item-level failure
// happens, and returns the unwrapped *ExitError so the caller can drill
// into Detail.Detail without re-doing the type assertion.
func assertDrivePullPartialFailure(t *testing.T, err error) *output.ExitError {
// assertDrivePullPartialFailure asserts that err is the typed partial-failure
// exit signal +pull returns on any item-level failure. The structured
// {summary, items, note} payload rides on stdout as an ok:false envelope via
// runtime.OutPartialFailure (in alignment with +push/+sync), so this helper
// only checks the exit-code signal; callers read the payload from stdout via
// splitDrivePullStdout.
func assertDrivePullPartialFailure(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected partial_failure ExitError, got nil")
t.Fatal("expected partial-failure exit signal, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI)
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail must be set on partial_failure")
}
if exitErr.Detail.Type != "partial_failure" {
t.Errorf("error.type = %q, want partial_failure", exitErr.Detail.Type)
}
return exitErr
}
// splitDrivePullDetail extracts the {summary, items[]} payload from the
// ExitError detail. We round-trip through JSON so test assertions don't
// depend on the concrete map types the production code happens to use.
func splitDrivePullDetail(t *testing.T, exitErr *output.ExitError) (map[string]interface{}, []map[string]interface{}) {
// splitDrivePullStdout extracts the {summary, items[]} payload from the
// stdout envelope written by runtime.Out. We round-trip through JSON so test
// assertions don't depend on the concrete map types the production code
// happens to use.
func splitDrivePullStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
t.Helper()
raw, err := json.Marshal(exitErr.Detail.Detail)
if err != nil {
t.Fatalf("marshal detail: %v", err)
var envelope struct {
Data struct {
Summary map[string]interface{} `json:"summary"`
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
var got struct {
Summary map[string]interface{} `json:"summary"`
Items []map[string]interface{} `json:"items"`
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal detail: %v\nraw=%s", err, string(raw))
if envelope.Data.Summary == nil {
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
}
if got.Summary == nil {
t.Fatalf("error.detail missing summary; raw=%s", string(raw))
}
return got.Summary, got.Items
return envelope.Data.Summary, envelope.Data.Items
}
// drivePullStdoutNote extracts the partial-failure "note" guidance from the
// stdout envelope. The human-readable note that used to live in the
// partial_failure ExitError message now rides on stdout alongside the
// summary + items payload.
func drivePullStdoutNote(t *testing.T, stdout []byte) string {
t.Helper()
var envelope struct {
Data struct {
Note string `json:"note"`
} `json:"data"`
}
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
return envelope.Data.Note
}

View File

@@ -5,7 +5,6 @@ package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -19,6 +18,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -112,26 +112,26 @@ var DrivePush = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes")
}
// Conditional scope pre-check: when --delete-remote --yes is set, the
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
@@ -185,11 +185,11 @@ var DrivePush = common.Shortcut{
// FileIO.Open's SafeInputPath check still accepts.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
@@ -217,7 +217,7 @@ var DrivePush = common.Shortcut{
// reruns.
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
var uploaded, skipped, failed, deletedRemote int
@@ -374,7 +374,7 @@ var DrivePush = common.Shortcut{
}
}
runtime.Out(map[string]interface{}{
payload := map[string]interface{}{
"summary": map[string]interface{}{
"uploaded": uploaded,
"skipped": skipped,
@@ -382,15 +382,15 @@ var DrivePush = common.Shortcut{
"deleted_remote": deletedRemote,
},
"items": items,
}, nil)
// Bump the exit code on any item-level failure (upload, overwrite,
// folder, or delete) so callers / scripts / agents can react. The
// summary + items[] envelope was just written to stdout via Out(),
// so ErrBare here only affects the exit code — the structured
// per-item context is still in the stdout JSON.
if failed > 0 {
return output.ErrBare(output.ExitAPI)
}
// On any item-level failure (upload, overwrite, folder, or delete) the
// command reports a partial failure: the summary + per-item items[] stay
// machine-readable on stdout (ok:false) and the process exits non-zero,
// so callers / scripts / agents can react.
if failed > 0 {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
}
@@ -466,7 +466,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
return nil
})
if err != nil {
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
return nil, nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
}
dirs := make([]string, 0, len(dirsSet))
for d := range dirsSet {
@@ -543,7 +543,7 @@ func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
}
remoteFiles[rel] = chosen
default:
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
return nil, nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remoteFolders, fileGroups, nil
@@ -567,7 +567,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
return "", err
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/files/create_folder",
nil,
@@ -581,7 +581,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
}
token := common.GetString(data, "token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir)
}
folderCache[relDir] = token
return token, nil
@@ -617,7 +617,7 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
f, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", "", common.WrapInputStatError(err)
return "", "", driveInputStatError(err)
}
defer f.Close()
@@ -644,27 +644,22 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
if errors.As(err, &exitErr) {
return "", "", err
}
return "", "", output.ErrNetwork("upload failed: %v", err)
return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
// Extract the token before the larkCode check: the backend can produce
// a partial-success response (code != 0 alongside a non-empty
// data.file_token) where bytes have already landed under that token.
// Returning "" here would force the caller to fall back to
// ClassifyAPIResponse returns the data even on a non-zero code, so the
// token is available on a partial-success response (code != 0 alongside a
// non-empty data.file_token) where bytes have already landed under that
// token. Returning "" would force the caller to fall back to
// entry.FileToken and silently lose the token Drive actually used,
// defeating the overwrite-error token-stability handling in Execute.
data, _ := result["data"].(map[string]interface{})
data, err := runtime.ClassifyAPIResponse(apiResp)
token := common.GetString(data, "file_token")
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
if err != nil {
return token, "", err
}
if token == "" {
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
version := common.GetString(data, "version")
if version == "" {
@@ -677,7 +672,7 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
// deployed backend hasn't shipped the field yet we surface the gap
// rather than report a phantom success — callers can downgrade to
// --if-exists=skip in the meantime.
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
return token, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
}
return token, version, nil
}
@@ -692,7 +687,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
if existingToken != "" {
prepareBody["file_token"] = existingToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
@@ -701,7 +696,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
blockNum := int(common.GetFloat(prepareResult, "block_num"))
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return "", output.Errorf(output.ExitAPI, "api_error",
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
@@ -717,7 +712,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
// one Open + Close + path-validation per block).
partFile, err := runtime.FileIO().Open(file.OpenPath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", driveInputStatError(err)
}
defer partFile.Close()
@@ -744,21 +739,16 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
if errors.As(doErr, &exitErr) {
return "", doErr
}
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
@@ -767,7 +757,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
}
token := common.GetString(finishResult, "file_token")
if token == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
}
return token, nil
}
@@ -776,7 +766,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
// never reached here because --delete-remote only iterates the type=file
// subset of the remote listing.
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
_, err := runtime.CallAPI(
_, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
map[string]interface{}{"type": driveTypeFile},

View File

@@ -871,21 +871,19 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
"--if-exists", "overwrite",
"--as", "bot",
}, f, stdout)
// Item-level failures bump the exit code via output.ErrBare(ExitAPI),
// preserving the structured items[] envelope on stdout. Older behavior
// was to silently return nil; the assertion below pins the new contract.
// Item-level failures report a partial failure: an ok:false items[]
// envelope on stdout + a non-zero exit via the partial-failure signal.
// Older behavior was to silently return nil; the assertion below pins
// the new contract.
if err == nil {
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, exitErr.Code)
}
if exitErr.Detail != nil {
t.Errorf("ErrBare should carry no Detail (the items[] envelope already covered the per-item error), got: %#v", exitErr.Detail)
if pfErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
}
out := stdout.String()
@@ -959,12 +957,19 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
if err == nil {
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI from output.ExitError, got %T %v", err, err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
}
out := stdout.String()
// Partial failure reports an ok:false result envelope on stdout (not a
// misleading ok:true) while still carrying BOTH the succeeded and failed
// items — consistent with the pre-change payload. The failed side is
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
if !strings.Contains(out, `"ok": false`) {
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
}
if !strings.Contains(out, `"failed": 1`) {
t.Errorf("expected failed=1, got: %s", out)
}
@@ -1042,9 +1047,9 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI ExitError, got %v", err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err)
}
out := stdout.String()
@@ -1065,7 +1070,7 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
// TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run
// with no failures must NOT bump the exit code. Without this the
// ErrBare-on-failure path could regress to "always non-zero" silently.
// partial-failure path could regress to "always non-zero" silently.
func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -6,7 +6,6 @@ package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
@@ -15,6 +14,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -219,13 +219,13 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
// that depends on the combination of flag values.
func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.Time) (map[string]interface{}, []string, error) {
if spec.Mine && len(spec.CreatorIDs) > 0 {
return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids")
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
}
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
}
if spec.Mine && userOpenID == "" {
return nil, nil, output.ErrValidation("--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config")
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine")
}
if err := validateDocTypes(spec.DocTypes); err != nil {
@@ -337,7 +337,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, output.ErrValidation("--page-size must be a number, got %q", raw)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be a number, got %q", raw).WithParam("--page-size")
}
if n <= 0 {
return 15, nil
@@ -355,23 +355,23 @@ func parseDriveSearchPageSize(raw string) (int, error) {
func validateDriveSearchIDs(spec driveSearchSpec) error {
for _, id := range spec.CreatorIDs {
if _, err := common.ValidateUserID(id); err != nil {
return output.ErrValidation("--creator-ids %q: %s", id, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
for _, id := range spec.ChatIDs {
if _, err := common.ValidateChatID(id); err != nil {
return output.ErrValidation("--chat-ids %q: %s", id, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
}
}
if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs {
return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
}
for _, id := range spec.SharerIDs {
if _, err := common.ValidateUserID(id); err != nil {
return output.ErrValidation("--sharer-ids %q: %s", id, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
}
}
return nil
@@ -382,7 +382,7 @@ func validateDocTypes(values []string) error {
// values are already upper-cased by readDriveSearchSpec; compare as-is
// so the filter we emit to the server matches what we validated.
if _, ok := driveSearchDocTypeSet[v]; !ok {
return output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v).WithParam("--doc-types")
}
}
return nil
@@ -417,13 +417,13 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error)
}
sinceUnix, err := parseTimeValue(spec.OpenedSince, now)
if err != nil {
return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-since %q: %s", spec.OpenedSince, err).WithParam("--opened-since")
}
var untilUnix int64
if spec.OpenedUntil != "" {
untilUnix, err = parseTimeValue(spec.OpenedUntil, now)
if err != nil {
return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-until %q: %s", spec.OpenedUntil, err).WithParam("--opened-until")
}
} else {
untilUnix = now.Unix()
@@ -440,7 +440,7 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error)
}
maxSecs := int64(driveSearchMaxOpenedSpanDays) * 24 * 3600
if spanSecs > maxSecs {
return "", output.ErrValidation(
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--opened-* window spans %d days, exceeds the %d-day (1-year) maximum; narrow the range or run multiple queries",
spanSecs/86400, driveSearchMaxOpenedSpanDays,
)
@@ -505,7 +505,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i
if since != "" {
unix, err := parseTimeValue(since, now)
if err != nil {
return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err)
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-since %q: %s", timeDimCLIName(key), since, err).WithParam(fmt.Sprintf("--%s-since", timeDimCLIName(key)))
}
if hourAggregated && unix%3600 != 0 {
snapped := floorHour(unix)
@@ -517,7 +517,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i
if until != "" {
unix, err := parseTimeValue(until, now)
if err != nil {
return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err)
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-until %q: %s", timeDimCLIName(key), until, err).WithParam(fmt.Sprintf("--%s-until", timeDimCLIName(key)))
}
if hourAggregated && unix%3600 != 0 {
snapped := ceilHour(unix)
@@ -571,7 +571,7 @@ var driveSearchRelativeRe = regexp.MustCompile(`^(\d+)([dmy])$`)
func parseTimeValue(input string, now time.Time) (int64, error) {
s := strings.TrimSpace(input)
if s == "" {
return 0, fmt.Errorf("empty value")
return 0, fmt.Errorf("empty value") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil {
@@ -616,34 +616,27 @@ func parseTimeValue(input string, now time.Time) (int64, error) {
}
}
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds")
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
}
func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) {
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
if err != nil {
return nil, enrichDriveSearchError(err)
}
return data, nil
}
// enrichDriveSearchError adds a +search-specific hint for known opaque Lark
// codes; other errors pass through unchanged.
// enrichDriveSearchError adds a +search-specific hint for a known opaque Lark
// code; other errors pass through unchanged. The hint is appended in place on
// the typed Problem, preserving its category / subtype / code / log_id.
func enrichDriveSearchError(err error) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != driveSearchErrUserNotVisible {
return err
}
if exitErr.Detail.Code != driveSearchErrUserNotVisible {
return err
}
detail := *exitErr.Detail
detail.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return &output.ExitError{
Code: exitErr.Code,
Detail: &detail,
Err: exitErr.Err,
}
p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return err
}
func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} {

View File

@@ -13,6 +13,8 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
@@ -258,6 +260,19 @@ func TestValidateDriveSearchIDs(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "--creator-ids") {
t.Fatalf("expected --creator-ids error, got: %v", err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if vErr.Param != "--creator-ids" {
t.Fatalf("Param = %q, want --creator-ids", vErr.Param)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
})
t.Run("bad chat id format", func(t *testing.T) {
@@ -625,51 +640,39 @@ func TestEnrichDriveSearchError(t *testing.T) {
}
})
t.Run("ExitError without Detail passes through", func(t *testing.T) {
t.Run("typed error with non-matching code passes through", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{Code: 1}
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("ExitError without Detail should pass through unchanged")
}
})
t.Run("ExitError with non-matching code passes through", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Code: 12345, Message: "other"},
}
orig := errclass.BuildAPIError(
map[string]any{"code": float64(12345), "msg": "other"},
errclass.ClassifyContext{},
)
if got := enrichDriveSearchError(orig); got != orig {
t.Fatalf("non-matching code should pass through unchanged")
}
})
t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) {
t.Run("matching code decorates the typed error's hint in place", func(t *testing.T) {
t.Parallel()
orig := &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{
Code: driveSearchErrUserNotVisible,
Message: "[99992351] user not visible",
Hint: "",
},
}
orig := errclass.BuildAPIError(
map[string]any{"code": float64(driveSearchErrUserNotVisible), "msg": "[99992351] user not visible"},
errclass.ClassifyContext{},
)
// Terminal decoration of an upstream error: the hint is set in place on
// the existing typed Problem and that same error is returned (no new
// error is constructed).
enriched := enrichDriveSearchError(orig)
eErr, ok := enriched.(*output.ExitError)
if enriched != orig {
t.Fatal("should decorate and return the upstream error, not construct a new one")
}
p, ok := errs.ProblemOf(enriched)
if !ok {
t.Fatalf("expected *output.ExitError, got %T", enriched)
t.Fatalf("expected a typed errs.* error, got %T", enriched)
}
if eErr == orig {
t.Fatal("should return a new ExitError, not mutate the original")
if !strings.Contains(p.Hint, "--creator-ids") {
t.Fatalf("hint should mention --creator-ids, got %q", p.Hint)
}
if orig.Detail.Hint != "" {
t.Fatal("original Detail.Hint must remain unchanged")
}
if !strings.Contains(eErr.Detail.Hint, "--creator-ids") {
t.Fatalf("hint should mention --creator-ids, got %q", eErr.Detail.Hint)
}
if eErr.Detail.Message != orig.Detail.Message {
t.Fatalf("Message should be preserved, got %q", eErr.Detail.Message)
if p.Message != "[99992351] user not visible" {
t.Fatalf("Message should be preserved, got %q", p.Message)
}
})
}
@@ -739,6 +742,18 @@ func TestBuildDriveSearchRequest(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "--mine") {
t.Fatalf("expected exclusion error, got: %v", err)
}
// Mutual-exclusion error: typed validation, but no single attributable
// flag, so Param stays empty.
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if vErr.Param != "" {
t.Fatalf("Param = %q, want empty for mutual-exclusion error", vErr.Param)
}
})
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {

View File

@@ -7,7 +7,7 @@ import (
"context"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,7 +36,7 @@ var DriveSecureLabelList = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
pageSize := runtime.Int("page-size")
if pageSize < 1 || pageSize > 10 {
return output.ErrValidation("--page-size must be between 1 and 10")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 10").WithParam("--page-size")
}
return nil
},
@@ -47,7 +47,7 @@ var DriveSecureLabelList = common.Shortcut{
Params(buildSecureLabelListParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPI("GET",
data, err := runtime.CallAPITyped("GET",
"/open-apis/drive/v2/my_secure_labels",
buildSecureLabelListParams(runtime),
nil,
@@ -95,7 +95,7 @@ var DriveSecureLabelUpdate = common.Shortcut{
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
data, err := runtime.CallAPI("PATCH",
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,

View File

@@ -17,7 +17,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -75,27 +75,27 @@ var DriveStatus = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
// Path safety (absolute paths, traversal, symlink escape) is enforced
// upfront by the framework helper so the error message references the
// correct flag name; FileIO().Stat below would do the same check, but
// surface --file in its hint.
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
// Conditional scope pre-check: quick mode only compares local mtime with
// Drive modified_time, so it must not be blocked on the download grant.
@@ -144,11 +144,11 @@ var DriveStatus = common.Shortcut{
// only possible under a Validate↔Execute race.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
@@ -263,7 +263,7 @@ func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalF
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
}
return files, nil
}
@@ -276,12 +276,12 @@ func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Ti
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return "", common.WrapInputStatError(err)
return "", driveInputStatError(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
return "", errs.NewInternalError(errs.SubtypeFileIO, "hash %s: %s", path, err).WithCause(err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
@@ -292,12 +292,12 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
return "", wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
}
defer resp.Body.Close()
h := sha256.New()
if _, err := io.Copy(h, resp.Body); err != nil {
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
return "", wrapDriveNetworkErr(err, "hash remote %s: %s", common.MaskToken(fileToken), err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View File

@@ -822,12 +822,15 @@ func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
if err == nil {
t.Fatal("expected walkLocalForStatus() to fail for missing root")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured ExitError, got %T", err)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "io" {
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
if !strings.Contains(err.Error(), "walk") {
t.Fatalf("expected walk-related error, got: %v", err)

View File

@@ -13,7 +13,7 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -72,23 +72,23 @@ var DriveSync = common.Shortcut{
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
}
return nil
},
@@ -118,15 +118,15 @@ var DriveSync = common.Shortcut{
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
}
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir")
}
// --- Phase 1: Compute diff (same logic as +status) ---
@@ -176,18 +176,18 @@ var DriveSync = common.Shortcut{
}
}
if len(typeConflicts) > 0 {
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
}
// Build the exact remote-file views that later execution will use so the
// diff phase classifies files against the same duplicate-resolution choice.
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
return errs.WrapInternal(err)
}
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
@@ -240,43 +240,19 @@ var DriveSync = common.Shortcut{
conflictResolutions := make(map[string]string, len(modified))
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--on-conflict=ask requires interactive stdin when modified files exist").WithParam("--on-conflict")
}
for _, entry := range modified {
resolved := onConflict
if resolved == driveSyncOnConflictAsk {
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
if err != nil {
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": 0,
"pushed": 0,
"skipped": 0,
"failed": 1,
},
"items": []driveSyncItem{{
RelPath: entry.RelPath,
FileToken: entry.FileToken,
Action: "failed",
Direction: "conflict",
Error: err.Error(),
}},
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
Detail: payload,
},
}
// Phase-1 setup abort: no sync operation ran yet, so this
// is not a batch partial-failure. driveSyncAskConflict
// already returns a typed *errs.ValidationError; propagate
// it unchanged rather than re-wrapping it as a synthetic
// partial_failure payload.
return err
}
}
conflictResolutions[entry.RelPath] = resolved
@@ -521,17 +497,12 @@ var DriveSync = common.Shortcut{
}
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
payload["note"] = fmt.Sprintf("%d item(s) failed during +sync", failed)
}
if failed > 0 {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
@@ -555,7 +526,7 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
if runtime.IO().In == nil {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath).WithParam("--on-conflict")
}
reader, ok := runtime.IO().In.(*bufio.Reader)
if !ok {
@@ -564,12 +535,12 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
}
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read conflict choice for %q: %s", relPath, err).WithParam("--on-conflict")
}
answer := strings.TrimSpace(strings.ToLower(line))
if answer == "" {
if errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath).WithParam("--on-conflict")
}
return driveSyncOnConflictRemoteWins, nil
}
@@ -583,7 +554,7 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
case "r", "remote", "remote-wins":
return driveSyncOnConflictRemoteWins, nil
default:
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)).WithParam("--on-conflict")
}
}
@@ -635,16 +606,16 @@ func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderC
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
return errs.NewInternalError(errs.SubtypeFileIO, "original path became a directory during rollback: %s", oldAbsPath)
}
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
return errs.NewInternalError(errs.SubtypeFileIO, "remove partial restored path %q: %s", oldAbsPath, err).WithCause(err)
}
} else if !os.IsNotExist(err) {
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
return errs.NewInternalError(errs.SubtypeFileIO, "stat original path %q during rollback: %s", oldAbsPath, err).WithCause(err)
}
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
return errs.NewInternalError(errs.SubtypeFileIO, "restore renamed local file %q: %s", oldAbsPath, err).WithCause(err)
}
return nil
}

View File

@@ -18,6 +18,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -1434,14 +1435,15 @@ func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) {
if err == nil {
t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
// Collecting conflict decisions runs in the Phase-1 setup pass, before
// any sync operation executes, so the EOF abort propagates the typed
// *errs.ValidationError unchanged rather than a synthetic partial_failure.
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") {
t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail)
if !strings.Contains(validationErr.Error(), "stdin reached EOF") {
t.Fatalf("expected EOF failure, got: %v", validationErr)
}
data, readErr := os.ReadFile("local/a.txt")
if readErr != nil {
@@ -1503,12 +1505,15 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) {
if err == nil {
t.Fatalf("expected EOF failure during ask planning\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "partial_failure" || !strings.Contains(exitErr.Error(), "stdin reached EOF") {
t.Fatalf("expected planning failure detail mentioning EOF, got: %#v", exitErr.Detail)
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(validationErr.Error(), "stdin reached EOF") {
t.Fatalf("expected planning failure mentioning EOF, got: %v", validationErr)
}
if data, readErr := os.ReadFile("local/a.txt"); readErr != nil || string(data) != "local-a" {
t.Fatalf("a.txt should remain untouched, readErr=%v content=%q", readErr, string(data))
@@ -1706,14 +1711,10 @@ func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected download failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") {
t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed pull item, got detail: %#v", stdout.String())
}
}
@@ -1758,14 +1759,10 @@ func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected ensure failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") {
t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed push item, got detail: %#v", stdout.String())
}
}
@@ -1810,14 +1807,10 @@ func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected upload failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") {
t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed upload item, got detail: %#v", stdout.String())
}
}
@@ -1875,14 +1868,10 @@ func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") {
t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed overwrite item, got detail: %#v", stdout.String())
}
}
@@ -1965,30 +1954,13 @@ func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected keep-both suffix exhaustion error\nstdout: %s", stdout.String())
}
// The error may be a plain ExitError (no Detail.Detail) or a
// partial_failure with items. Either way it must mention the
// suffix exhaustion.
errMsg := err.Error()
// The suffix exhaustion message may be in the top-level error or
// inside a partial_failure detail item. Check both.
foundSuffixError := strings.Contains(errMsg, "could not generate a unique rel_path")
if !foundSuffixError {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
for _, item := range items {
if strings.Contains(item.Error, "could not generate a unique rel_path") {
foundSuffixError = true
break
}
}
if !foundSuffixError {
t.Fatalf("expected suffix exhaustion error, got: %s; detail: %#v", errMsg, exitErr.Detail.Detail)
}
} else {
t.Fatalf("expected suffix exhaustion error, got: %s", errMsg)
}
// The suffix-exhaustion failure is an item-level conflict failure, so
// it surfaces as the partial-failure signal: a typed PartialFailureError
// on the error channel and the ok:false items[] payload (carrying the
// suffix message) on stdout via OutPartialFailure.
assertDriveSyncPartialFailure(t, err)
if !strings.Contains(stdout.String(), "could not generate a unique rel_path") {
t.Fatalf("expected suffix exhaustion error in stdout items, got: %s", stdout.String())
}
}
@@ -2341,14 +2313,10 @@ func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") {
t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed modified pull item, got detail: %#v", stdout.String())
}
}
@@ -2411,14 +2379,10 @@ func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) {
if err == nil {
t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") {
t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected rollback failure in item error, got detail: %#v", stdout.String())
}
}
@@ -2500,14 +2464,10 @@ func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") {
t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed item with create_folder error, got detail: %#v", stdout.String())
}
}
@@ -2704,7 +2664,7 @@ func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) {
// TestDriveSyncKeepBothRollbackSucceedsOnPullFailure verifies the full
// keep-both rollback path: when the pull download fails after the local
// file has been renamed, the rollback restores the original file and
// the error is reported as a partial_failure.
// the failure is reported via the partial-failure signal.
func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) {
syncTestConfig := &core.CliConfig{
AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -2762,14 +2722,10 @@ func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected keep-both pull failure with rollback\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 || !strings.Contains(items[0].Error, "save failed") {
t.Fatalf("expected save failure in item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected save failure in item, got detail: %#v", stdout.String())
}
// Rollback should have restored the original file.
@@ -2978,14 +2934,10 @@ func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) {
if err == nil {
t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String())
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured ExitError, got: %v", err)
}
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
items, _ := detailMap["items"].([]driveSyncItem)
assertDriveSyncPartialFailure(t, err)
items := driveSyncStdoutItems(t, stdout.Bytes())
if len(items) == 0 {
t.Fatalf("expected failed item, got detail: %#v", exitErr.Detail.Detail)
t.Fatalf("expected failed item, got detail: %#v", stdout.String())
}
// The reported token should be the new one from the partial-success
// response, not the stale existingToken ("tok_a").
@@ -3095,3 +3047,39 @@ func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) {
t.Fatalf("error should mention local directory, got: %v", err)
}
}
// assertDriveSyncPartialFailure asserts that err is the typed partial-failure
// exit signal +sync returns on any item-level failure. The structured
// {detection, diff, summary, items, note} payload rides on stdout as an
// ok:false envelope via runtime.OutPartialFailure (in alignment with
// +push/+pull), so this helper only checks the exit-code signal; callers read
// the payload from stdout.
func assertDriveSyncPartialFailure(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected partial-failure exit signal, got nil")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
}
// driveSyncStdoutItems extracts the items[] payload from the stdout envelope
// written by runtime.Out. The per-item failure context that used to live in
// the partial_failure ExitError detail now rides on stdout.
func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
t.Helper()
var envelope struct {
Data struct {
Items []driveSyncItem `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout, &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
}
return envelope.Data.Items
}

View File

@@ -9,8 +9,8 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,34 +43,34 @@ var DriveTaskResult = common.Shortcut{
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
}
// Validate required params based on scenario
switch scenario {
case "import", "export":
if runtime.Str("ticket") == "" {
return output.ErrValidation("--ticket is required for %s scenario", scenario)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ticket is required for %s scenario", scenario).WithParam("--ticket")
}
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for %s scenario", scenario)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
}
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
}
// For export scenario, file-token is required
if scenario == "export" && runtime.Str("file-token") == "" {
return output.ErrValidation("--file-token is required for export scenario")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required for export scenario").WithParam("--file-token")
}
if scenario == "export" {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
}
@@ -261,9 +261,10 @@ func requireDriveScopes(storedScopes string, required []string) error {
return nil
}
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithMissingScopes(missing...).
WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))
}
func missingDriveScopes(storedScopes string, required []string) []string {
@@ -408,10 +409,10 @@ func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[strin
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
return wikiMoveTaskQueryStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
@@ -426,7 +427,7 @@ func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiM
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
if task == nil {
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return wikiMoveTaskQueryStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
status := wikiMoveTaskQueryStatus{
@@ -490,10 +491,10 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
// rather than the per-node array used by wiki move.
func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_space"},
@@ -505,7 +506,7 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
task := common.GetMap(data, "task")
if task == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
resolvedTaskID := common.GetString(task, "task_id")
@@ -558,10 +559,10 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
// keep drive from depending on shortcuts/wiki.
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, output.ErrValidation("%s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_node"},
@@ -573,7 +574,7 @@ func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map
task := common.GetMap(data, "task")
if task == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
resolvedTaskID := common.GetString(task, "task_id")

View File

@@ -13,10 +13,12 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -86,6 +88,16 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
})
}
}
@@ -428,6 +440,16 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
t.Fatalf("expected missing wiki scope error, got %v", err)
}
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if permErr.Subtype != errs.SubtypeMissingScope {
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "wiki:space:read" {
t.Fatalf("MissingScopes = %v, want [wiki:space:read]", permErr.MissingScopes)
}
})
t.Run(scenario+"/accepts wiki scope", func(t *testing.T) {
t.Parallel()
@@ -663,6 +685,19 @@ func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
// A successful API call (code==0) that omits the `task` field is a
// malformed RESPONSE, not a user error: classify as internal /
// invalid_response (exit 5), not an API business error (exit 1).
var iErr *errs.InternalError
if !errors.As(err, &iErr) {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
if iErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", iErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want ExitInternal (%d)", got, output.ExitInternal)
}
}
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {

View File

@@ -5,7 +5,6 @@ package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -15,6 +14,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -151,7 +151,7 @@ var DriveUpload = common.Shortcut{
info, err := runtime.FileIO().Stat(spec.FilePath)
if err != nil {
return common.WrapInputStatError(err)
return driveInputStatError(err)
}
fileSize := info.Size()
@@ -194,13 +194,13 @@ var DriveUpload = common.Shortcut{
func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error {
if driveUploadFlagExplicitlyEmpty(runtime, "file-token") {
return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite").WithParam("--file-token")
}
if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") {
return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token").WithParam("--folder-token")
}
if driveUploadFlagExplicitlyEmpty(runtime, "wiki-token") {
return common.FlagErrorf("--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token").WithParam("--wiki-token")
}
targets := 0
@@ -211,21 +211,21 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe
targets++
}
if targets > 1 {
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token and --wiki-token are mutually exclusive")
}
if spec.FolderToken != "" {
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
}
if spec.WikiToken != "" {
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--wiki-token")
}
}
if spec.FileToken != "" {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
}
return nil
@@ -240,7 +240,7 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return driveUploadResult{}, common.WrapInputStatError(err)
return driveUploadResult{}, driveInputStatError(err)
}
defer f.Close()
@@ -265,23 +265,16 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
if errors.As(err, &exitErr) {
return driveUploadResult{}, err
}
return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err)
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return driveUploadResult{}, err
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
return driveUploadResult{
FileToken: fileToken,
@@ -304,7 +297,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if existingFileToken != "" {
prepareBody["file_token"] = existingFileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return driveUploadResult{}, err
}
@@ -316,7 +309,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
blockNum := int(blockNumF)
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error",
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
}
@@ -334,7 +327,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return driveUploadResult{}, common.WrapInputStatError(err)
return driveUploadResult{}, driveInputStatError(err)
}
fd := larkcore.NewFormdata()
@@ -354,16 +347,11 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if errors.As(err, &exitErr) {
return driveUploadResult{}, err
}
return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err)
}
var partResult map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
}
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
msg, _ := partResult["msg"].(string)
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return driveUploadResult{}, err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
@@ -374,14 +362,14 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
"upload_id": uploadID,
"block_num": blockNum,
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
if err != nil {
return driveUploadResult{}, err
}
fileToken := common.GetString(finishResult, "file_token")
if fileToken == "" {
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
}
return driveUploadResult{

View File

@@ -16,8 +16,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/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -34,10 +34,10 @@ type driveVersionHistorySpec struct {
func validateDriveNumericValue(value, flagName, valueLabel string) error {
value = strings.TrimSpace(value)
if value == "" {
return output.ErrValidation("%s cannot be empty", flagName)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", flagName).WithParam(flagName)
}
if !driveVersionNumberRe.MatchString(value) {
return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a numeric %s", flagName, valueLabel).WithParam(flagName)
}
return nil
}
@@ -52,10 +52,10 @@ func validateDriveCursorValue(value, flagName string) error {
func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if spec.Limit < 1 || spec.Limit > 200 {
return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --limit %d: must be between 1 and 200", spec.Limit).WithParam("--limit")
}
if spec.Cursor != "" {
if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil {
@@ -180,7 +180,7 @@ var DriveVersionHistory = common.Shortcut{
Cursor: strings.TrimSpace(runtime.Str("cursor")),
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
http.MethodGet,
fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)),
driveVersionHistoryParams(spec),
@@ -214,7 +214,7 @@ type driveVersionGetSpec struct {
func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDriveVersionValue(spec.Version, "--version"); err != nil {
return err
@@ -223,7 +223,7 @@ func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersi
return nil
}
if _, err := validate.SafeOutputPath(spec.Output); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
return nil
}
@@ -299,7 +299,7 @@ var DriveVersionGet = common.Shortcut{
},
})
if err != nil {
return output.ErrNetwork("download failed: %s", err)
return wrapDriveNetworkErr(err, "download failed: %s", err)
}
defer resp.Body.Close()
@@ -315,10 +315,10 @@ var DriveVersionGet = common.Shortcut{
outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "")
}
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
return output.ErrValidation("unsafe output path: %s", resolveErr)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
}
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite {
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
@@ -326,7 +326,7 @@ var DriveVersionGet = common.Shortcut{
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return common.WrapSaveErrorByCategory(err, "io")
return driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(outputPath)
@@ -354,7 +354,7 @@ type driveVersionMutationSpec struct {
func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
return validateDriveVersionValue(spec.Version, "--version")
}
@@ -392,7 +392,7 @@ var DriveVersionRevert = common.Shortcut{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
if _, err := runtime.CallAPI(
if _, err := runtime.CallAPITyped(
http.MethodPost,
fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)),
nil,
@@ -439,7 +439,7 @@ var DriveVersionDelete = common.Shortcut{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Version: strings.TrimSpace(runtime.Str("version")),
}
if _, err := runtime.CallAPI(
if _, err := runtime.CallAPITyped(
http.MethodPost,
fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)),
nil,

View File

@@ -5,14 +5,17 @@ package drive
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"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"
)
@@ -53,6 +56,16 @@ func TestValidateDriveVersionHistorySpec(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
})
}
}
@@ -255,6 +268,13 @@ func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "output file already exists") {
t.Fatalf("expected output exists error, got %v", err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument || vErr.Param != "--output" {
t.Fatalf("typed shape = subtype %q param %q, want invalid_argument/--output", vErr.Subtype, vErr.Param)
}
}
func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) {

View File

@@ -11,9 +11,10 @@ import (
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -85,7 +86,7 @@ func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext
if pageToken != "" {
params["page_token"] = pageToken
}
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
result, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files", params, nil)
if err != nil {
return nil, err
}
@@ -176,24 +177,27 @@ func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemote
return duplicates
}
// Deprecated: duplicateRemotePathError produces a legacy *output.ExitError
// that predates the typed error contract introduced by errs/. New code MUST
// NOT use it — duplicate-path signals should move to a typed
// *errs.ValidationError (with duplicates metadata as a typed extension
// field) when the drive shortcut migrates to typed errors. This helper is
// retained only while existing call sites are migrated; it will be removed
// once they have moved to the typed surface.
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "duplicate_remote_path",
Message: "multiple Drive entries map to the same rel_path",
Detail: map[string]interface{}{
"duplicates_remote": duplicates,
},
},
// duplicateRemotePathError reports that multiple Drive entries resolve to the
// same rel_path. Each colliding rel_path becomes one InvalidParam whose Name is
// the rel_path and whose Reason enumerates the colliding entries (type +
// file_token), so an AI agent reading the typed envelope can identify exactly
// which Drive objects collide without re-listing the folder.
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) error {
params := make([]errs.InvalidParam, 0, len(duplicates))
for _, d := range duplicates {
descriptions := make([]string, 0, len(d.Entries))
for _, entry := range d.Entries {
descriptions = append(descriptions, fmt.Sprintf("%s %s", entry.Type, entry.FileToken))
}
params = append(params, errs.InvalidParam{
Name: d.RelPath,
Reason: fmt.Sprintf("%d Drive entries collide here: %s", len(d.Entries), strings.Join(descriptions, ", ")),
})
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%d rel_path(s) map to multiple Drive entries", len(duplicates)).
WithHint("resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help").
WithParams(params...)
}
const (
@@ -300,7 +304,7 @@ func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
if len(files) == 0 {
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
return driveRemoteEntry{}, errs.NewInternalError(errs.SubtypeUnknown, "no Drive entries available for strategy %q", strategy)
}
candidates := append([]driveRemoteEntry(nil), files...)
sortRemoteFiles(candidates, strategy)
@@ -385,7 +389,7 @@ func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[st
return candidate, nil
}
}
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
return "", errs.NewInternalError(errs.SubtypeUnknown, "could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
}
// joinRelDrive joins a rel_path base with an entry name using "/".

View File

@@ -18,8 +18,10 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli {{service}} +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 | Reference |
|----------|------|-----------|
{{shortcut_rows}}
{{/shortcuts}}
{{#actions}}

View File

@@ -96,6 +96,8 @@ lark-cli auth login --domain apps
Shortcut 是对常用操作的高级封装(`lark-cli apps +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-apps-create.md) | 创建妙搭应用name / description / icon-url |

View File

@@ -70,6 +70,8 @@ Calendar (日历)
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |

View File

@@ -14,6 +14,8 @@ metadata:
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
**CRITICAL — 执行任何命令之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |

View File

@@ -59,6 +59,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |

View File

@@ -260,6 +260,8 @@ lark-cli drive permission.members create \
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |

View File

@@ -70,6 +70,8 @@ Item types for feed-layer flags:
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |

View File

@@ -464,6 +464,8 @@ lark-cli mail user_mailbox.folders create \
Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+message`](references/lark-mail-message.md) | Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images. |

View File

@@ -41,6 +41,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |

View File

@@ -111,6 +111,8 @@ Minutes (妙记) ← minute_token 标识
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |

View File

@@ -16,6 +16,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |

View File

@@ -123,6 +123,7 @@ lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_
## 安全规则
- **文件路径只接受相对路径**`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON优先用 stdin 传入,避免路径和转义问题。
- **禁止输出密钥**appSecret、accessToken到终端明文。
- **写入/删除操作前必须确认用户意图**。
- 用 `--dry-run` 预览危险请求。

View File

@@ -179,6 +179,8 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
### Spreadsheet Management
对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md)

View File

@@ -256,6 +256,8 @@ Slides (演示文稿)
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |

View File

@@ -33,6 +33,8 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |

View File

@@ -94,6 +94,8 @@ lark-cli vc +notes --meeting-ids "$MID"
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 类型 | 说明 |
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |

View File

@@ -137,6 +137,8 @@ Meeting (视频会议)
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |

View File

@@ -33,6 +33,8 @@ metadata:
## Shortcuts
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |

View File

@@ -59,6 +59,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |

View File

@@ -74,8 +74,9 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
if statusResult.ExitCode == 0 {
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) {
t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
if !strings.Contains(statusResult.Stderr, `"type": "validation"`) ||
!strings.Contains(statusResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+status stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
@@ -91,8 +92,9 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
if pullFailResult.ExitCode == 0 {
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) {
t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
if !strings.Contains(pullFailResult.Stderr, `"type": "validation"`) ||
!strings.Contains(pullFailResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+pull stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)