mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
28 Commits
feat/lark-
...
v1.0.48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac116e7ca3 | ||
|
|
5e6a3eb857 | ||
|
|
493b3cce95 | ||
|
|
abc0553f21 | ||
|
|
a82a486508 | ||
|
|
c000dc3a44 | ||
|
|
256df8c0fb | ||
|
|
7a0dbe057b | ||
|
|
8ce38793a7 | ||
|
|
54e646edc9 | ||
|
|
b07a6003f9 | ||
|
|
03a589978f | ||
|
|
b3fcf55611 | ||
|
|
2f35ce3724 | ||
|
|
7e7f716a82 | ||
|
|
1670a794f6 | ||
|
|
33de28fd1a | ||
|
|
85c7280d8b | ||
|
|
24ce3ec151 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef | ||
|
|
0aa9e96d18 |
@@ -57,6 +57,14 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
@@ -65,10 +73,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/|shortcuts/mail/)
|
||||
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/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -94,6 +115,23 @@ linters:
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
# ── 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
|
||||
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||
- **contact**: Add contact skill domain guidance (#1144)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Use JSON skills list during update (#1251)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||
- **vc-agent**: Require explicit leave request (#1260)
|
||||
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
@@ -964,6 +1026,9 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
91
cmd/config/init_probe.go
Normal 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
|
||||
}
|
||||
288
cmd/config/init_probe_test.go
Normal file
288
cmd/config/init_probe_test.go
Normal 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)
|
||||
}
|
||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
312
cmd/root.go
312
cmd/root.go
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
@@ -50,20 +51,6 @@ EXAMPLES:
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
@@ -85,7 +72,15 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -149,29 +144,49 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -257,6 +272,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)
|
||||
@@ -269,6 +291,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -335,35 +370,204 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
suggestions := suggest.Closest(unknown, available, 6)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
},
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -372,10 +576,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
@@ -426,6 +635,11 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,149 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "--badflag" {
|
||||
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||
}
|
||||
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||
}
|
||||
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||
if _, present := detail[key]; !present {
|
||||
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||
// must still fail structured (missing_subcommand) rather than fall through to
|
||||
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -164,7 +308,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
got, _ := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -175,3 +319,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g --json":
|
||||
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -102,7 +102,7 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut, f.IOStreams.IsTerminal)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
|
||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
@@ -75,8 +75,6 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set("x-tt-env", "ppe_moa_canvas")
|
||||
h.Set("x-use-ppe", "1")
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
70
internal/credential/tat_fetch.go
Normal file
70
internal/credential/tat_fetch.go
Normal 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
|
||||
}
|
||||
237
internal/credential/tat_fetch_test.go
Normal file
237
internal/credential/tat_fetch_test.go
Normal 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)
|
||||
}
|
||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@ 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"
|
||||
case errs.SubtypeQuotaExceeded:
|
||||
return "reduce the request volume or free quota, then retry after the relevant quota resets"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
|
||||
17
internal/errclass/codemeta_drive.go
Normal file
17
internal/errclass/codemeta_drive.go
Normal 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") }
|
||||
43
internal/errclass/codemeta_drive_test.go
Normal file
43
internal/errclass/codemeta_drive_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
internal/errclass/codemeta_mail.go
Normal file
20
internal/errclass/codemeta_mail.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
var mailCodeMeta = map[int]CodeMeta{
|
||||
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
|
||||
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
|
||||
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
|
||||
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
|
||||
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
|
||||
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(mailCodeMeta, "mail") }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -165,6 +165,10 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
}
|
||||
|
||||
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
if r.Err != nil {
|
||||
|
||||
@@ -188,6 +188,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills ls -g",
|
||||
},
|
||||
{
|
||||
name: "list global json",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.ListGlobalSkillsJSON()
|
||||
},
|
||||
want: "-y skills ls -g --json",
|
||||
},
|
||||
{
|
||||
name: "install skill primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -57,6 +58,28 @@ func ParseSkillsList(text string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseGlobalSkillsJSON(text string) []string {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -77,8 +100,11 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip indented lines (Agents: ...)
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
if strings.HasPrefix(trimmed, "Agents:") {
|
||||
continue
|
||||
}
|
||||
|
||||
if isGlobalSkillsSectionHeader(trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -91,21 +117,24 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
candidate := parts[0]
|
||||
|
||||
// Validate and add
|
||||
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(candidate, "@"); at > 0 {
|
||||
candidate = candidate[:at]
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func isGlobalSkillsSectionHeader(line string) bool {
|
||||
switch line {
|
||||
case "General", "Project", "Local":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
|
||||
func parseOfficialSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -195,6 +224,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
@@ -239,10 +269,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local := []string{}
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult != nil && localResult.Err == nil {
|
||||
local = ParseSkillsList(localResult.Stdout.String())
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
// --- Step 3: Read previous state ---
|
||||
@@ -270,6 +299,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 {
|
||||
@@ -294,6 +327,24 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
textResult := runner.ListGlobalSkills()
|
||||
if textResult != nil && textResult.Err == nil {
|
||||
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
|
||||
// when incremental sync is not possible. On success it writes a state file so that
|
||||
// subsequent syncs can use incremental mode. When official is non-nil the state
|
||||
|
||||
@@ -67,6 +67,49 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
General
|
||||
lark-apps ~/.agents/skills/lark-apps
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-apps", "lark-base"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSON(t *testing.T) {
|
||||
input := `[
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
|
||||
{"name":""},
|
||||
{"name":" "},
|
||||
{"name":"bad skill"}
|
||||
]`
|
||||
got := ParseGlobalSkillsJSON(input)
|
||||
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`[]`,
|
||||
} {
|
||||
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
|
||||
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -113,14 +156,18 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
@@ -146,6 +193,19 @@ func globalSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsJSONOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
@@ -153,7 +213,16 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
|
||||
f.listedGlobalJSON++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalJSONOut)
|
||||
r.Err = f.globalJSONErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
f.listedGlobalText++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
@@ -186,8 +255,9 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
@@ -199,6 +269,12 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
|
||||
if runner.listedGlobalJSON != 1 {
|
||||
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
|
||||
}
|
||||
if runner.listedGlobalText != 0 {
|
||||
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
@@ -262,48 +338,107 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
|
||||
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
|
||||
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
|
||||
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want 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", runner.installedAll)
|
||||
}
|
||||
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
|
||||
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
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", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
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{})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
@@ -311,6 +446,7 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -342,6 +478,7 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
@@ -440,6 +577,7 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -464,6 +602,7 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -504,8 +643,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
|
||||
@@ -73,18 +73,7 @@ func redactProxyURL(raw string) string {
|
||||
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
|
||||
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
//
|
||||
// The warning is suppressed entirely when interactive is false — i.e. stdin is
|
||||
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
|
||||
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
|
||||
// stray stderr warning then corrupts the parsed payload. Suppressing in the
|
||||
// non-interactive case keeps machine-consumed output clean, while human
|
||||
// interactive sessions still get the security notice. Passing interactive=false
|
||||
// does not consume the once guard, so a later interactive call can still warn.
|
||||
func WarnIfProxied(w io.Writer, interactive bool) {
|
||||
if !interactive {
|
||||
return
|
||||
}
|
||||
func WarnIfProxied(w io.Writer) {
|
||||
proxyWarningOnce.Do(func() {
|
||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||
// Shared), so its warning and disable instructions take precedence.
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
@@ -70,30 +70,13 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_SilentWhenNonInteractive(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
// Non-interactive (interactive=false) mirrors agent / CI / piped invocations
|
||||
// where stdin is not a TTY. The proxy warning must be suppressed so callers
|
||||
// that parse stdout as JSON — often merging streams with `2>&1` — are not
|
||||
// corrupted by a stray stderr line.
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, false)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning in non-interactive mode, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -105,7 +88,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
||||
@@ -122,10 +105,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
first := buf.String()
|
||||
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
second := buf.String()
|
||||
|
||||
if first == "" {
|
||||
@@ -154,7 +137,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "127.0.0.1:3128") {
|
||||
@@ -186,7 +169,7 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "custom CA") {
|
||||
@@ -212,7 +195,7 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "s3cret") {
|
||||
@@ -260,7 +243,7 @@ func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf, true)
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
||||
|
||||
139
lint/errscontract/rule_no_legacy_common_helper_call.go
Normal file
139
lint/errscontract/rule_no_legacy_common_helper_call.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
|
||||
// has migrated to typed errs.* envelopes. On these paths, calls to common's
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
var legacyCommonHelperReplacements = map[string]string{
|
||||
"FlagErrorf": "common.ValidationErrorf",
|
||||
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
|
||||
"AtLeastOne": "common.AtLeastOneTyped",
|
||||
"ExactlyOne": "common.ExactlyOneTyped",
|
||||
"ValidatePageSize": "common.ValidatePageSizeTyped",
|
||||
"ValidateChatID": "common.ValidateChatIDTyped",
|
||||
"ValidateUserID": "common.ValidateUserIDTyped",
|
||||
"ValidateSafePath": "common.ValidateSafePathTyped",
|
||||
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
|
||||
"WrapInputStatError": "common.WrapInputStatErrorTyped",
|
||||
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
|
||||
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
|
||||
"HandleApiResult": "runtime.CallAPITyped",
|
||||
}
|
||||
|
||||
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
|
||||
// APIs on migrated paths — direct calls and function-value references alike,
|
||||
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
|
||||
// helpers return legacy output envelopes or bare errors, so migrated domains
|
||||
// should use their typed-aware replacements.
|
||||
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
|
||||
if !isMigratedCommonHelperPath(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
|
||||
}
|
||||
localNames, dotImported := resolveCommonNames(file)
|
||||
var out []Violation
|
||||
report := func(pos token.Pos, name, replacement string) {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_common_helper_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
|
||||
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
|
||||
})
|
||||
}
|
||||
// Pass 1: qualified references (common.X / alias.X). Record every
|
||||
// selector field so the dot-import pass below never mistakes another
|
||||
// package's same-named field for a common helper.
|
||||
selFields := make(map[*ast.Ident]struct{})
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selFields[sel.Sel] = struct{}{}
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
|
||||
report(sel.Pos(), sel.Sel.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Pass 2: unqualified references under a dot import.
|
||||
if dotImported {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ident, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, isField := selFields[ident]; isField {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
|
||||
report(ident.Pos(), ident.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isMigratedCommonHelperPath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedCommonHelperPaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveCommonNames(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 != commonImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
names["common"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
147
lint/errscontract/rule_no_legacy_envelope_literal.go
Normal file
147
lint/errscontract/rule_no_legacy_envelope_literal.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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/",
|
||||
"shortcuts/mail/",
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
73
lint/errscontract/rule_no_legacy_runtime_api_call.go
Normal file
73
lint/errscontract/rule_no_legacy_runtime_api_call.go
Normal 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
|
||||
}
|
||||
@@ -593,3 +593,413 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
|
||||
helpers := []string{
|
||||
"FlagErrorf",
|
||||
"MutuallyExclusive",
|
||||
"AtLeastOne",
|
||||
"ExactlyOne",
|
||||
"ValidatePageSize",
|
||||
"ValidateChatID",
|
||||
"ValidateUserID",
|
||||
"ValidateSafePath",
|
||||
"RejectDangerousChars",
|
||||
"WrapInputStatError",
|
||||
"WrapSaveErrorByCategory",
|
||||
"ResolveOpenIDs",
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
t.Run(path+"_"+helper, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.` + helper + `()
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "common."+helper) {
|
||||
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.ValidationErrorf("typed")
|
||||
common.MutuallyExclusiveTyped(nil, "a", "b")
|
||||
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
|
||||
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
|
||||
common.WrapSaveErrorTyped(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed helpers must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
c.FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() error {
|
||||
f := common.FlagErrorf
|
||||
return f("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ 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))...)
|
||||
all = append(all, CheckNoLegacyCommonHelperCall(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))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.45",
|
||||
"version": "1.0.48",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
42
shortcuts/base/base_block_create.go
Normal file
42
shortcuts/base/base_block_create.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-create",
|
||||
Description: "Create a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
|
||||
"Creates a folder, table, docx, dashboard, or workflow entry.",
|
||||
"Do not pass null for --parent-id. Omit it to create at the root level.",
|
||||
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockCreate(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockCreate(runtime)
|
||||
},
|
||||
}
|
||||
35
shortcuts/base/base_block_delete.go
Normal file
35
shortcuts/base/base_block_delete.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-delete",
|
||||
Description: "Delete a block",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:block:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Deletes the block identified by --block-id.",
|
||||
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
|
||||
"Different block types may have independent backing resources; deletion follows backend semantics.",
|
||||
"Use +base-block-list first when you need to confirm the target block id.",
|
||||
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockDelete(runtime)
|
||||
},
|
||||
}
|
||||
43
shortcuts/base/base_block_list.go
Normal file
43
shortcuts/base/base_block_list.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockList = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-list",
|
||||
Description: "List blocks in a base",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:block:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token>",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
|
||||
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
|
||||
"This command returns the full backend list. It intentionally does not expose limit or offset.",
|
||||
"Pass --type to list only one resource type.",
|
||||
"Pass --parent-id to list only direct children of a folder.",
|
||||
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockList(runtime)
|
||||
},
|
||||
}
|
||||
42
shortcuts/base/base_block_move.go
Normal file
42
shortcuts/base/base_block_move.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockMove = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-move",
|
||||
Description: "Move a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
|
||||
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
|
||||
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
"Omit --parent-id to move the block to root; do not pass null.",
|
||||
"--before-id and --after-id are mutually exclusive.",
|
||||
"When moving a folder, its children remain under that folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockMove(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockMove,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockMove(runtime)
|
||||
},
|
||||
}
|
||||
179
shortcuts/base/base_block_ops.go
Normal file
179
shortcuts/base/base_block_ops.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
|
||||
|
||||
func baseBlockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
|
||||
}
|
||||
|
||||
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(buildBaseBlockListBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks").
|
||||
Body(buildBaseBlockCreateBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
|
||||
Body(buildBaseBlockMoveBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
|
||||
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return common.FlagErrorf("--type must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockList(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
|
||||
if blockType == "" {
|
||||
return
|
||||
}
|
||||
blocks, ok := data["blocks"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]interface{})
|
||||
if !ok || blockMap["type"] != blockType {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
data["blocks"] = filtered
|
||||
data["total"] = len(filtered)
|
||||
}
|
||||
|
||||
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"type": strings.TrimSpace(runtime.Str("type")),
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"parent_id": nil}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
|
||||
body["before_id"] = beforeID
|
||||
}
|
||||
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
|
||||
body["after_id"] = afterID
|
||||
}
|
||||
return body
|
||||
}
|
||||
37
shortcuts/base/base_block_rename.go
Normal file
37
shortcuts/base/base_block_rename.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockRename = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-rename",
|
||||
Description: "Rename a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
|
||||
"Renames the block identified by --block-id.",
|
||||
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
|
||||
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockRename(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockRename,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockRename(runtime)
|
||||
},
|
||||
}
|
||||
@@ -32,6 +32,29 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
}
|
||||
|
||||
func TestDryRunBaseBlockOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
|
||||
|
||||
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
|
||||
|
||||
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
|
||||
|
||||
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
|
||||
|
||||
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
|
||||
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
|
||||
}
|
||||
|
||||
func TestDryRunFieldOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -71,6 +94,29 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
filteredListRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"sort-json": `[{"field":"Due","desc":true}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordList(ctx, filteredListRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records",
|
||||
"limit=20",
|
||||
"filter=%7B",
|
||||
"Status",
|
||||
"Todo",
|
||||
"sort=%5B",
|
||||
"Due",
|
||||
)
|
||||
|
||||
commaFieldRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-id": {"A,B", "C"}},
|
||||
@@ -99,6 +145,33 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
`"limit":500`,
|
||||
)
|
||||
|
||||
searchFlagRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"keyword": "Alice",
|
||||
"view-id": "viw_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`,
|
||||
"sort-json": `[{"field":"Updated At","desc":true}]`,
|
||||
},
|
||||
map[string][]string{
|
||||
"search-field": {"Name"},
|
||||
"field-id": {"Name", "Status"},
|
||||
},
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordSearch(ctx, searchFlagRT),
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
|
||||
`"keyword":"Alice"`,
|
||||
`"search_fields":["Name"]`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`,
|
||||
`"sort":[{"desc":true,"field":"Updated At"}]`,
|
||||
)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
|
||||
@@ -411,6 +411,108 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
listStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
|
||||
},
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
},
|
||||
}
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
|
||||
},
|
||||
}
|
||||
renameStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
|
||||
},
|
||||
}
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc"},
|
||||
},
|
||||
}
|
||||
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
|
||||
t.Fatalf("list err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
|
||||
t.Fatalf("list stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
|
||||
t.Fatalf("list body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("create stdout=%s", got)
|
||||
}
|
||||
createBody := decodeCapturedJSONBody(t, createStub)
|
||||
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
|
||||
t.Fatalf("create body=%#v", createBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
|
||||
t.Fatalf("move err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
|
||||
t.Fatalf("move stdout=%s", got)
|
||||
}
|
||||
moveBody := decodeCapturedJSONBody(t, moveStub)
|
||||
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
|
||||
t.Fatalf("move body=%#v", moveBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
|
||||
t.Fatalf("rename stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
|
||||
t.Fatalf("rename body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("delete err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("delete stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -974,7 +1076,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
@@ -990,12 +1092,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(body, `"keyword":"Created"`) ||
|
||||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
|
||||
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
|
||||
!strings.Contains(body, `"offset":0`) ||
|
||||
!strings.Contains(body, `"limit":2`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with flag filter sort and projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Status"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_status"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "Created",
|
||||
"--search-field", "Title",
|
||||
"--field-id", "Title",
|
||||
"--field-id", "Status",
|
||||
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
|
||||
"--limit", "20",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
|
||||
}
|
||||
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
|
||||
t.Fatalf("captured body=%#v", body)
|
||||
}
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["logic"] != "and" {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
conditions := filter["conditions"].([]interface{})
|
||||
if len(conditions) != 2 {
|
||||
t.Fatalf("conditions=%#v", conditions)
|
||||
}
|
||||
sortConfig := body["sort"].([]interface{})
|
||||
if len(sortConfig) != 2 {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with filter json file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
tmp := t.TempDir()
|
||||
withBaseWorkingDir(t, tmp)
|
||||
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
|
||||
t.Fatalf("write filter err=%v", err)
|
||||
}
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "A",
|
||||
"--search-field", "Title",
|
||||
"--filter-json", "@filter.json",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
body := string(searchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
@@ -188,6 +189,7 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
@@ -241,6 +243,30 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
err := validateBaseBlockMove(runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
|
||||
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
|
||||
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
|
||||
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -254,35 +280,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Text equality filter",
|
||||
"Option intersection filter",
|
||||
"Query priority",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
"for keyword search only",
|
||||
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
|
||||
"keyword for record search",
|
||||
"field ID or name to search",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"Happy path fields: keyword (string), search_fields",
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Example: lark-cli base +record-search",
|
||||
"Example with filter/sort JSON",
|
||||
"Text equality filter",
|
||||
"Query priority",
|
||||
"Use --json only when you need to pass the full search body directly",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
"inventing search JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -607,7 +637,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -724,6 +754,79 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: BaseBaseBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-list --base-token <base_token>",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`--type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"returned id is the table-id, dashboard-id, or workflow-id",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: BaseBaseBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Recursive folder deletion is not supported.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -885,11 +988,11 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
if BaseRecordList.Validate == nil {
|
||||
t.Fatalf("record list validate should reject invalid query flags before dry-run")
|
||||
}
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
@@ -900,6 +1003,58 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("record upsert map validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record list filter-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search flag validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
"sort-json": `[{"field":"Title","desc":false}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,8 @@ var BaseRecordList = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
@@ -29,10 +31,21 @@ var BaseRecordList = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
`Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`,
|
||||
`Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`,
|
||||
`Number equality filter: --filter-json '{"logic":"and","conditions":[["Score","==",95]]}'`,
|
||||
`Date equality filter: --filter-json '{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}'`,
|
||||
`Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`,
|
||||
`Sort priority follows --sort-json array order: --sort-json '[{"field":"Updated","desc":true},{"field":"Title","desc":false}]'`,
|
||||
formatRecordQueryPriorityTip(),
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
|
||||
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
|
||||
@@ -217,6 +217,9 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params.Set("view_id", viewID)
|
||||
}
|
||||
if err := applyRecordQueryToURLValues(runtime, params); err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode()
|
||||
return common.NewDryRunAPI().
|
||||
GET(path).
|
||||
@@ -237,8 +240,12 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
var body map[string]interface{}
|
||||
if strings.TrimSpace(runtime.Str("json")) != "" {
|
||||
body, _ = recordSearchJSONBody(runtime)
|
||||
} else {
|
||||
body, _ = recordSearchFlagBody(runtime)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search").
|
||||
Body(body).
|
||||
@@ -388,6 +395,9 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params["view_id"] = viewID
|
||||
}
|
||||
if err := applyRecordQueryToParams(runtime, params); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -420,8 +430,13 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
var body map[string]interface{}
|
||||
var err error
|
||||
if strings.TrimSpace(runtime.Str("json")) != "" {
|
||||
body, err = recordSearchJSONBody(runtime)
|
||||
} else {
|
||||
body, err = recordSearchFlagBody(runtime)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
248
shortcuts/base/record_query.go
Normal file
248
shortcuts/base/record_query.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
recordFilterJSONFlag = "filter-json"
|
||||
recordSortJSONFlag = "sort-json"
|
||||
recordSortMaxCount = 10
|
||||
)
|
||||
|
||||
func recordFilterFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: recordFilterJSONFlag,
|
||||
Desc: `filter JSON object or @file, same shape as view filter JSON; overrides --view-id view filters`,
|
||||
Input: []string{common.File},
|
||||
}
|
||||
}
|
||||
|
||||
func recordSortFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: recordSortJSONFlag,
|
||||
Desc: `sort JSON array or @file, e.g. [{"field":"Updated","desc":true}]; also accepts {"sort_config":[...]}; order is priority; max 10`,
|
||||
Input: []string{common.File},
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
|
||||
if _, err := parseRecordFilterFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseRecordSortFlag(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
|
||||
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
|
||||
if filterRaw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
return parseJSONObject(pc, filterRaw, recordFilterJSONFlag)
|
||||
}
|
||||
|
||||
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
|
||||
if sortRaw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
value, err := parseJSONValue(pc, sortRaw, recordSortJSONFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeRecordSortValue(value, "--"+recordSortJSONFlag)
|
||||
}
|
||||
|
||||
func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) {
|
||||
var sortConfig []interface{}
|
||||
if parsed, ok := value.([]interface{}); ok {
|
||||
sortConfig = parsed
|
||||
} else if obj, ok := value.(map[string]interface{}); ok {
|
||||
rawSortConfig, ok := obj["sort_config"]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
parsed, ok := rawSortConfig.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
|
||||
}
|
||||
sortConfig = parsed
|
||||
} else {
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
if len(sortConfig) > recordSortMaxCount {
|
||||
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||
}
|
||||
return sortConfig, nil
|
||||
}
|
||||
|
||||
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func applyRecordQueryToParams(runtime *common.RuntimeContext, params map[string]interface{}) error {
|
||||
filter, err := parseRecordFilterFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filter"] = filterJSON
|
||||
}
|
||||
sortConfig, err := parseRecordSortFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sortConfig) > 0 {
|
||||
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["sort"] = sortJSON
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyRecordQueryToURLValues(runtime *common.RuntimeContext, params url.Values) error {
|
||||
filter, err := parseRecordFilterFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filter"] = []string{filterJSON}
|
||||
}
|
||||
sortConfig, err := parseRecordSortFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sortConfig) > 0 {
|
||||
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["sort"] = []string{sortJSON}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]interface{}) error {
|
||||
filter, err := parseRecordFilterFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
body["filter"] = filter
|
||||
}
|
||||
sortConfig, err := parseRecordSortFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sortConfig) > 0 {
|
||||
body["sort"] = sortConfig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
||||
body["keyword"] = keyword
|
||||
}
|
||||
searchFields := runtime.StrArray("search-field")
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
body["view_id"] = viewID
|
||||
}
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
body["offset"] = offset
|
||||
body["limit"] = common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
return body, applyRecordQueryToBody(runtime, body)
|
||||
}
|
||||
|
||||
func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeRecordSearchJSONBody(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, applyRecordQueryToBody(runtime, body)
|
||||
}
|
||||
|
||||
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
|
||||
if rawSort, ok := body["sort"]; ok {
|
||||
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
|
||||
body["sort"] = sortConfig
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return common.FlagErrorf("--keyword is required unless --json is used")
|
||||
}
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
return common.FlagErrorf("--search-field is required unless --json is used")
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
runtime.Changed("offset") ||
|
||||
runtime.Changed("limit")
|
||||
}
|
||||
|
||||
func formatRecordQueryPriorityTip() string {
|
||||
return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag)
|
||||
}
|
||||
161
shortcuts/base/record_query_test.go
Normal file
161
shortcuts/base/record_query_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRecordSortValue(t *testing.T) {
|
||||
t.Run("array", func(t *testing.T) {
|
||||
sortConfig, err := normalizeRecordSortValue([]interface{}{
|
||||
map[string]interface{}{"field": "Updated", "desc": true},
|
||||
}, "--sort-json")
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(sortConfig) != 1 {
|
||||
t.Fatalf("sortConfig=%#v", sortConfig)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrapped sort_config", func(t *testing.T) {
|
||||
sortConfig, err := normalizeRecordSortValue(map[string]interface{}{
|
||||
"sort_config": []interface{}{
|
||||
map[string]interface{}{"field": "Updated", "desc": false},
|
||||
},
|
||||
}, "--json.sort")
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
first := sortConfig[0].(map[string]interface{})
|
||||
if first["field"] != "Updated" || first["desc"] != false {
|
||||
t.Fatalf("sortConfig=%#v", sortConfig)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid wrapper", func(t *testing.T) {
|
||||
_, err := normalizeRecordSortValue(map[string]interface{}{"sort": []interface{}{}}, "--sort-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "sort_config array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid sort_config type", func(t *testing.T) {
|
||||
_, err := normalizeRecordSortValue(map[string]interface{}{"sort_config": "Updated"}, "--sort-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "--sort-json.sort_config must be a JSON array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid scalar", func(t *testing.T) {
|
||||
_, err := normalizeRecordSortValue("Updated", "--sort-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be a JSON array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyRecordQueryToParams(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`,
|
||||
"sort-json": `{"sort_config":[{"field":"Updated","desc":true}]}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
params := map[string]interface{}{"view_id": "viw_1"}
|
||||
if err := applyRecordQueryToParams(runtime, params); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if params["view_id"] != "viw_1" {
|
||||
t.Fatalf("params=%#v", params)
|
||||
}
|
||||
var filter map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(params["filter"].(string)), &filter); err != nil {
|
||||
t.Fatalf("filter err=%v", err)
|
||||
}
|
||||
if filter["logic"] != "and" {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
var sortConfig []interface{}
|
||||
if err := json.Unmarshal([]byte(params["sort"].(string)), &sortConfig); err != nil {
|
||||
t.Fatalf("sort err=%v", err)
|
||||
}
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Updated" || firstSort["desc"] != true {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRecordQueryToURLValues(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"filter-json": `{"logic":"or","conditions":[["Score",">",90]]}`,
|
||||
"sort-json": `[{"field":"Score","desc":false}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
params := url.Values{"view_id": {"viw_1"}}
|
||||
if err := applyRecordQueryToURLValues(runtime, params); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := params.Get("view_id"); got != "viw_1" {
|
||||
t.Fatalf("view_id=%q", got)
|
||||
}
|
||||
if !strings.Contains(params.Get("filter"), `"logic":"or"`) || !strings.Contains(params.Get("sort"), `"field":"Score"`) {
|
||||
t.Fatalf("params=%#v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSearchJSONBodyAppliesQueryFlagOverrides(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"json": `{"keyword":"urgent","search_fields":["Title"],"filter":{"logic":"and","conditions":[["Status","==","Done"]]},"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`,
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`,
|
||||
"sort-json": `[{"field":"Score","desc":true}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
body, err := recordSearchJSONBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
conditions := filter["conditions"].([]interface{})
|
||||
statusCondition := conditions[0].([]interface{})
|
||||
if statusCondition[2] != "Todo" {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
sortConfig := body["sort"].([]interface{})
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Score" || firstSort["desc"] != true {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSearchJSONBodyNormalizesWrappedSort(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"json": `{"keyword":"urgent","search_fields":["Title"],"sort":{"sort_config":[{"field":"Updated","desc":false}]}}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
body, err := recordSearchJSONBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
sortConfig := body["sort"].([]interface{})
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Updated" || firstSort["desc"] != false {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
}
|
||||
@@ -20,21 +20,34 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true},
|
||||
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
|
||||
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
|
||||
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`,
|
||||
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
||||
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
||||
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --keyword Alice --search-field Name --field-id Name --field-id Status --limit 20`,
|
||||
`Example with filter/sort JSON: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --keyword Alice --search-field Name --filter-json @filter.json --sort-json '[{"field":"Updated","desc":true}]'`,
|
||||
`Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`,
|
||||
`Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`,
|
||||
`Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`,
|
||||
`Sort priority follows --sort-json array order.`,
|
||||
formatRecordQueryPriorityTip(),
|
||||
"Use +record-search for keyword matching; use --filter-json for structured conditions and --sort-json for result ordering.",
|
||||
"Use --json only when you need to pass the full search body directly.",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordJSON(runtime)
|
||||
return validateRecordSearchFlags(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
|
||||
@@ -8,6 +8,11 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
BaseBaseBlockRename,
|
||||
BaseBaseBlockDelete,
|
||||
BaseTableList,
|
||||
BaseTableGet,
|
||||
BaseTableCreate,
|
||||
|
||||
200
shortcuts/common/call_api_typed_test.go
Normal file
200
shortcuts/common/call_api_typed_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,9 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
|
||||
}
|
||||
|
||||
// HandleApiResult checks for network/API errors and returns the "data" field.
|
||||
//
|
||||
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
|
||||
// self-driven requests) for typed error envelopes.
|
||||
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -57,6 +58,7 @@ type DriveMediaMultipartUploadConfig struct {
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
|
||||
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
var fileReader io.Reader
|
||||
if cfg.Reader != nil {
|
||||
@@ -98,6 +100,52 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
|
||||
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
// UploadDriveMediaAllTyped is the typed-error counterpart of
|
||||
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
|
||||
// transport failures as typed network errors, and API failures are classified
|
||||
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
|
||||
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
var fileReader io.Reader
|
||||
if cfg.Reader != nil {
|
||||
fileReader = cfg.Reader
|
||||
} else {
|
||||
f, err := runtime.FileIO().Open(cfg.FilePath)
|
||||
if err != nil {
|
||||
return "", WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer f.Close()
|
||||
fileReader = f
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", cfg.FileName)
|
||||
fd.AddField("parent_type", cfg.ParentType)
|
||||
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
|
||||
if cfg.ParentNode != nil {
|
||||
fd.AddField("parent_node", *cfg.ParentNode)
|
||||
}
|
||||
if cfg.Extra != "" {
|
||||
fd.AddField("extra", cfg.Extra)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
|
||||
}
|
||||
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
|
||||
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
||||
// upload_prepare expects parent_node to be present even when the caller wants
|
||||
// the service default/root behavior, so multipart callers pass an explicit
|
||||
@@ -130,6 +178,43 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
|
||||
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
|
||||
}
|
||||
|
||||
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
|
||||
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
|
||||
// CallAPITyped, malformed session plans surface as invalid-response internal
|
||||
// errors, and per-part transport/API failures are classified the same way as
|
||||
// UploadDriveMediaAllTyped.
|
||||
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
||||
// upload_prepare expects parent_node to be present even when the caller wants
|
||||
// the service default/root behavior, so multipart callers pass an explicit
|
||||
// string instead of relying on field omission like upload_all does.
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": cfg.FileName,
|
||||
"parent_type": cfg.ParentType,
|
||||
"parent_node": cfg.ParentNode,
|
||||
"size": cfg.FileSize,
|
||||
}
|
||||
if cfg.Extra != "" {
|
||||
prepareBody["extra"] = cfg.Extra
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
||||
|
||||
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
|
||||
}
|
||||
|
||||
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
||||
// The backend chooses both chunk size and chunk count. Validate them once so
|
||||
// the streaming loop can follow the returned plan without re-checking shape.
|
||||
@@ -280,3 +365,122 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
|
||||
}
|
||||
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
|
||||
}
|
||||
|
||||
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
|
||||
// message so callers see which upload step failed. Non-typed errors are
|
||||
// returned unchanged.
|
||||
func prefixDriveMediaUploadProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = action + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
|
||||
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
|
||||
// malformed plan as a typed invalid-response internal error.
|
||||
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
||||
session := DriveMediaMultipartUploadSession{
|
||||
UploadID: GetString(data, "upload_id"),
|
||||
BlockSize: int64(GetFloat(data, "block_size")),
|
||||
BlockNum: int(GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" {
|
||||
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
|
||||
}
|
||||
if session.BlockSize <= 0 {
|
||||
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
if session.BlockNum <= 0 {
|
||||
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
|
||||
// with a typed invalid-response internal error for a missing file_token.
|
||||
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
|
||||
fileToken := GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
|
||||
// with typed errors for file-open, file-read, and per-part upload failures.
|
||||
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
|
||||
var r io.Reader
|
||||
if cfg.Reader != nil {
|
||||
r = cfg.Reader
|
||||
} else {
|
||||
f, err := runtime.FileIO().Open(cfg.FilePath)
|
||||
if err != nil {
|
||||
return WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
bufferSize := session.BlockSize
|
||||
if bufferSize <= 0 || bufferSize > maxInt {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
buffer := make([]byte, int(bufferSize))
|
||||
remaining := cfg.FileSize
|
||||
// Follow the server-declared block plan exactly; upload_finish expects the
|
||||
// same block count returned by upload_prepare.
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return WrapInputStatErrorTyped(readErr)
|
||||
}
|
||||
|
||||
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
|
||||
remaining -= int64(n)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
|
||||
fd.AddFile("file", bytes.NewReader(chunk))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
|
||||
}
|
||||
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
|
||||
}
|
||||
|
||||
305
shortcuts/common/drive_media_upload_typed_test.go
Normal file
305
shortcuts/common/drive_media_upload_typed_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_typed_123"},
|
||||
},
|
||||
})
|
||||
|
||||
payload := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
|
||||
Reader: bytes.NewReader(payload),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: int64(len(payload)),
|
||||
ParentType: "docx_image",
|
||||
ParentNode: strPtr("blk_parent"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
|
||||
}
|
||||
if fileToken != "file_typed_123" {
|
||||
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "upload rejected",
|
||||
},
|
||||
})
|
||||
|
||||
payload := []byte{0x01}
|
||||
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
|
||||
Reader: bytes.NewReader(payload),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: int64(len(payload)),
|
||||
ParentType: "docx_image",
|
||||
ParentNode: strPtr("blk_parent"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category = %s, want api", p.Category)
|
||||
}
|
||||
if p.Code != 999 {
|
||||
t.Fatalf("code = %d, want 999", p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, "upload media failed: ") || !strings.Contains(p.Message, "upload rejected") {
|
||||
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaAllTypedFileOpenFailure(t *testing.T) {
|
||||
runtime, _ := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
|
||||
FilePath: "missing.bin",
|
||||
FileName: "missing.bin",
|
||||
FileSize: 1,
|
||||
ParentType: "docx_image",
|
||||
ParentNode: strPtr("blk_parent"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected typed validation error, got %T (%v)", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartTypedBuildsPreparePartsAndFinish(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
size := MaxDriveMediaUploadSinglePartSize + 1
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_typed_1",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_typed_multi"},
|
||||
},
|
||||
})
|
||||
|
||||
payload := bytes.Repeat([]byte{0xCD}, int(size))
|
||||
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
|
||||
Reader: bytes.NewReader(payload),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: size,
|
||||
ParentType: "docx_image",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
|
||||
}
|
||||
if fileToken != "file_typed_multi" {
|
||||
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_multi")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "missing upload id",
|
||||
data: map[string]interface{}{
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: no upload_id returned",
|
||||
},
|
||||
{
|
||||
name: "missing block size",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_size returned",
|
||||
},
|
||||
{
|
||||
name: "missing block num",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_num returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartTypedPartAPIFailure(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "chunk rejected",
|
||||
},
|
||||
})
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 999 {
|
||||
t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, "upload media part failed: ") || !strings.Contains(p.Message, "chunk rejected") {
|
||||
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "upload media finish failed: no file_token returned") {
|
||||
t.Fatalf("message = %q", p.Message)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -210,12 +211,6 @@ func (ctx *RuntimeContext) Int(name string) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// Int64 returns an int64 flag value.
|
||||
func (ctx *RuntimeContext) Int64(name string) int64 {
|
||||
v, _ := ctx.Cmd.Flags().GetInt64(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// Float64 returns a float64 flag value (non-integer numbers).
|
||||
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||
@@ -256,6 +251,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.
|
||||
//
|
||||
@@ -520,6 +642,8 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
// - Other errors → readMsg prefix (default "cannot read file")
|
||||
//
|
||||
// Pass an optional readMsg to override the non-path-validation message prefix.
|
||||
//
|
||||
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
|
||||
func WrapInputStatError(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -534,9 +658,28 @@ func WrapInputStatError(err error, readMsg ...string) error {
|
||||
return output.ErrValidation("%s: %s", msg, err)
|
||||
}
|
||||
|
||||
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
|
||||
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
msg := "cannot read file"
|
||||
if len(readMsg) > 0 && readMsg[0] != "" {
|
||||
msg = readMsg[0]
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
||||
// using standardized messages and the given error category (e.g. "api_error", "io").
|
||||
// Path validation errors always use ErrValidation (exit code 2).
|
||||
//
|
||||
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
|
||||
func WrapSaveErrorByCategory(err error, category string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -552,6 +695,28 @@ func WrapSaveErrorByCategory(err error, category string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
|
||||
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
|
||||
// "internal" wire type: call sites migrating from a custom category
|
||||
// (e.g. "io", "api_error") change their envelope's type field.
|
||||
func WrapSaveErrorTyped(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)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePath checks that path is a valid relative input path within the
|
||||
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
||||
// valid or does not exist yet; returns an error only for illegal paths
|
||||
@@ -575,28 +740,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
|
||||
}
|
||||
@@ -771,19 +955,26 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
}
|
||||
if shortcut.PrintFlagSchema != nil {
|
||||
// --print-schema is pure local introspection; relax cobra's
|
||||
// required-flag gate so callers don't need to fill in unrelated
|
||||
// flags just to ask for a schema. ValidateRequiredFlags runs
|
||||
// after PreRunE in cobra, so clearing the annotation here is the
|
||||
// supported way to opt out.
|
||||
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
||||
onInvoke := shortcut.OnInvoke
|
||||
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
||||
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
||||
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
||||
// surface even when the call later fails on a missing required flag.
|
||||
// - --print-schema: pure local introspection; relax the required-flag
|
||||
// gate so callers don't fill in unrelated flags just to ask for a
|
||||
// schema (clearing the annotation here is the supported opt-out).
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); !want {
|
||||
return nil
|
||||
if onInvoke != nil {
|
||||
onInvoke()
|
||||
}
|
||||
if relaxRequiredForSchema {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); want {
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -808,6 +999,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// structured ExitError so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if _, ok := err.(*output.ExitError); !ok {
|
||||
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
@@ -942,7 +1140,8 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
|
||||
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
@@ -951,15 +1150,19 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(fl.Input, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
@@ -976,15 +1179,19 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(fl.Input, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
|
||||
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
@@ -1012,7 +1219,8 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
|
||||
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1020,7 +1228,8 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
|
||||
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if s.DryRun == nil {
|
||||
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
|
||||
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
||||
WithParam("--dry-run")
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
|
||||
dryResult := s.DryRun(rctx.ctx, rctx)
|
||||
@@ -1073,10 +1282,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d int
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "int64":
|
||||
var d int64
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int64(fl.Name, d, desc)
|
||||
case "float64":
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
@@ -1108,13 +1313,23 @@ 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")
|
||||
}
|
||||
if s.PrintFlagSchema != nil {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
// Guard against a shortcut that already declares these reserved
|
||||
// introspection flags: pflag panics on a duplicate registration.
|
||||
// Mirrors the Lookup guard on --format above.
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
|
||||
@@ -96,3 +96,116 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
|
||||
// --print-schema / --flag-name flags are registered defensively: a shortcut
|
||||
// that already declares same-named flags must not trigger pflag's duplicate-
|
||||
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
|
||||
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+introspect",
|
||||
Description: "x",
|
||||
// The shortcut's own flags collide with the names the runner auto-
|
||||
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
|
||||
Flags: []Flag{
|
||||
{Name: "print-schema", Desc: "user-defined collision"},
|
||||
{Name: "flag-name", Desc: "user-defined collision"},
|
||||
},
|
||||
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
|
||||
}
|
||||
}()
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+introspect"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
t.Error("print-schema flag should still exist after the guarded registration")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
t.Error("flag-name flag should still exist after the guarded registration")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -142,6 +143,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -171,6 +174,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -212,6 +216,7 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
63
shortcuts/common/runner_partial_failure_test.go
Normal file
63
shortcuts/common/runner_partial_failure_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
22
shortcuts/common/runner_validation_test.go
Normal file
22
shortcuts/common/runner_validation_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
|
||||
rctx := newTestRuntime(map[string]string{"mode": "delete"})
|
||||
err := validateEnumFlags(rctx, []Flag{
|
||||
{Name: "mode", Enum: []string{"append", "overwrite"}},
|
||||
})
|
||||
assertValidationParam(t, err, "--mode")
|
||||
}
|
||||
|
||||
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
|
||||
err := handleShortcutDryRun(nil, nil, &Shortcut{
|
||||
Service: "doc",
|
||||
Command: "fetch",
|
||||
})
|
||||
assertValidationParam(t, err, "--dry-run")
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "int64" | "float64" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
@@ -58,6 +58,14 @@ type Shortcut struct {
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
// call later fails on a missing required flag (which short-circuits before
|
||||
// Validate/Execute). The backward-compat aliases use it to record a
|
||||
// deprecation notice that must surface regardless of whether the call
|
||||
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
|
||||
OnInvoke func()
|
||||
|
||||
// PrintFlagSchema, when non-nil, opts this shortcut into the
|
||||
// `--print-schema --flag-name <name>` runtime introspection contract.
|
||||
// The framework auto-injects those two system flags and short-circuits
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -13,9 +14,32 @@ import (
|
||||
// open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName is
|
||||
// used in error messages to point the user at the offending CLI flag.
|
||||
//
|
||||
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
|
||||
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, output.ErrValidation("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveOpenIDsTyped expands the special identifier "me" to the current
|
||||
// user's open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName names
|
||||
// the flag being resolved (e.g. "--user-ids") and is recorded on the typed
|
||||
// error.
|
||||
func ResolveOpenIDsTyped(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, ValidationErrorf("%s", msg).WithParam(flagName)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, string) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
return nil, ""
|
||||
}
|
||||
currentUserID := runtime.UserOpenId()
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
@@ -23,7 +47,7 @@ func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(id, "me") {
|
||||
if currentUserID == "" {
|
||||
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
return nil, fmt.Sprintf("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
}
|
||||
id = currentUserID
|
||||
}
|
||||
@@ -34,5 +58,5 @@ func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
return out, ""
|
||||
}
|
||||
|
||||
@@ -75,3 +75,24 @@ func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
|
||||
t.Fatalf("case-insensitive dedup failed: got %v, want [ou_abc123]", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_MeWithoutLogin_ReturnsTypedValidation(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("")
|
||||
_, err := ResolveOpenIDsTyped("--user-ids", []string{"me"}, rt)
|
||||
validationErr := assertValidationParam(t, err, "--user-ids")
|
||||
if !strings.Contains(validationErr.Message, "--user-ids") {
|
||||
t.Fatalf("error should mention the offending flag name; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_ExpandsMeAndDedups(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"ou_self", "ou_a"}
|
||||
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
|
||||
t.Fatalf("got %v, want %v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,26 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// FlagErrorf returns a validation error with flag context (exit code 2).
|
||||
//
|
||||
// Deprecated: use ValidationErrorf for typed error envelopes.
|
||||
func FlagErrorf(format string, args ...any) error {
|
||||
return output.ErrValidation(format, args...)
|
||||
}
|
||||
|
||||
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
|
||||
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
// MutuallyExclusive checks that at most one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
|
||||
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
|
||||
var set []string
|
||||
for _, f := range flags {
|
||||
@@ -32,7 +42,25 @@ func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
|
||||
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
|
||||
var set []string
|
||||
for _, f := range flags {
|
||||
val := rt.Str(f)
|
||||
if val != "" {
|
||||
set = append(set, "--"+f)
|
||||
}
|
||||
}
|
||||
if len(set) > 1 {
|
||||
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
|
||||
WithParams(invalidParams(set, "mutually exclusive")...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AtLeastOne checks that at least one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use AtLeastOneTyped for typed error envelopes.
|
||||
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
|
||||
for _, f := range flags {
|
||||
if rt.Str(f) != "" {
|
||||
@@ -46,7 +74,24 @@ func AtLeastOne(rt *RuntimeContext, flags ...string) error {
|
||||
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
|
||||
}
|
||||
|
||||
// AtLeastOneTyped checks that at least one of the given flags is set.
|
||||
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
for _, f := range flags {
|
||||
if rt.Str(f) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
names := make([]string, len(flags))
|
||||
for i, f := range flags {
|
||||
names[i] = "--" + f
|
||||
}
|
||||
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
|
||||
WithParams(invalidParams(names, "required; specify at least one")...)
|
||||
}
|
||||
|
||||
// ExactlyOne checks that exactly one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use ExactlyOneTyped for typed error envelopes.
|
||||
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
|
||||
if err := AtLeastOne(rt, flags...); err != nil {
|
||||
return err
|
||||
@@ -54,8 +99,18 @@ func ExactlyOne(rt *RuntimeContext, flags ...string) error {
|
||||
return MutuallyExclusive(rt, flags...)
|
||||
}
|
||||
|
||||
// ExactlyOneTyped checks that exactly one of the given flags is set.
|
||||
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
if err := AtLeastOneTyped(rt, flags...); err != nil {
|
||||
return err
|
||||
}
|
||||
return MutuallyExclusiveTyped(rt, flags...)
|
||||
}
|
||||
|
||||
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
//
|
||||
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
|
||||
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
if s == "" {
|
||||
@@ -71,6 +126,25 @@ func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, m
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
param := "--" + flagName
|
||||
if s == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
|
||||
}
|
||||
if n < minVal || n > maxVal {
|
||||
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
|
||||
WithParam(param)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ParseIntBounded parses an int flag and clamps it to [min, max].
|
||||
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
|
||||
v := rt.Int(name)
|
||||
@@ -87,13 +161,26 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
|
||||
// working directory. It catches traversal, symlink escape, and control
|
||||
// characters by delegating to FileIO.ResolvePath. Works for both file and
|
||||
// directory paths.
|
||||
//
|
||||
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
|
||||
func ValidateSafePath(fio fileio.FileIO, path string) error {
|
||||
_, err := fio.ResolvePath(path)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateSafePathTyped ensures path resolves within the current working directory.
|
||||
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
|
||||
_, err := fio.ResolvePath(path)
|
||||
if err != nil {
|
||||
return ValidationErrorf("%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousChars returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
//
|
||||
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
|
||||
func RejectDangerousChars(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
@@ -108,3 +195,31 @@ func RejectDangerousChars(paramName, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousCharsTyped returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
func RejectDangerousCharsTyped(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
|
||||
WithParam(paramName)
|
||||
}
|
||||
if r == 0x7F {
|
||||
return ValidationErrorf("parameter %q contains DEL character", paramName).
|
||||
WithParam(paramName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
|
||||
WithParam(paramName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidParams(names []string, reason string) []errs.InvalidParam {
|
||||
params := make([]errs.InvalidParam, len(names))
|
||||
for i, name := range names {
|
||||
params[i] = errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -11,10 +11,31 @@ import (
|
||||
|
||||
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided.
|
||||
//
|
||||
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
|
||||
func ValidateChatID(input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided. param names the flag being
|
||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||
func ValidateChatIDTyped(param, input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", ValidationErrorf("%s", msg).WithParam(param)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
func normalizeChatID(input string) (string, string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", output.ErrValidation("chat ID cannot be empty")
|
||||
return "", "chat ID cannot be empty"
|
||||
}
|
||||
// Extract from URL if present
|
||||
if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") {
|
||||
@@ -28,19 +49,40 @@ func ValidateChatID(input string) (string, error) {
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(input, "oc_") {
|
||||
return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)")
|
||||
return "", "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)"
|
||||
}
|
||||
return input, nil
|
||||
return input, ""
|
||||
}
|
||||
|
||||
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
|
||||
//
|
||||
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
|
||||
func ValidateUserID(input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
|
||||
// param names the flag being validated (e.g. "--creator-ids") and is
|
||||
// recorded on the typed error.
|
||||
func ValidateUserIDTyped(param, input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", ValidationErrorf("%s", msg).WithParam(param)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func normalizeUserID(input string) (string, string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", output.ErrValidation("user ID cannot be empty")
|
||||
return "", "user ID cannot be empty"
|
||||
}
|
||||
if !strings.HasPrefix(input, "ou_") {
|
||||
return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)")
|
||||
return "", "invalid user ID format, should start with 'ou_' (e.g., ou_abc123)"
|
||||
}
|
||||
return input, nil
|
||||
return input, ""
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -26,6 +30,24 @@ func newTestRuntime(flags map[string]string) *RuntimeContext {
|
||||
return &RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func assertValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if param != "" && validationErr.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func TestMutuallyExclusive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -69,6 +91,109 @@ func TestMutuallyExclusive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorf_ReturnsTypedInvalidArgument(t *testing.T) {
|
||||
err := ValidationErrorf("bad %s", "flag")
|
||||
validationErr := assertValidationParam(t, err, "")
|
||||
if validationErr.Message != "bad flag" {
|
||||
t.Fatalf("Message = %q, want %q", validationErr.Message, "bad flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedFlagGroupHelpers_ReturnValidationParams(t *testing.T) {
|
||||
t.Run("mutually exclusive", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
|
||||
validationErr := assertValidationParam(t, MutuallyExclusiveTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--a" || validationErr.Params[1].Name != "--b" {
|
||||
t.Fatalf("Params names = %+v, want --a/--b", validationErr.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("at least one", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "", "b": ""})
|
||||
validationErr := assertValidationParam(t, AtLeastOneTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
if !strings.Contains(validationErr.Message, "--a or --b") {
|
||||
t.Fatalf("Message = %q, want flag group", validationErr.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly one", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
|
||||
validationErr := assertValidationParam(t, ExactlyOneTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatePageSizeTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"page-size": "nope"})
|
||||
_, err := ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
|
||||
assertValidationParam(t, err, "--page-size")
|
||||
|
||||
rt = newTestRuntime(map[string]string{"page-size": "30"})
|
||||
_, err = ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
|
||||
assertValidationParam(t, err, "--page-size")
|
||||
}
|
||||
|
||||
func TestValidateIDTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
chatID, err := ValidateChatIDTyped("--chat-ids", "https://example.feishu.cn/foo/oc_abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChatIDTyped valid URL: %v", err)
|
||||
}
|
||||
if chatID != "oc_abc" {
|
||||
t.Fatalf("chatID = %q, want oc_abc", chatID)
|
||||
}
|
||||
assertValidationParam(t, func() error {
|
||||
_, err := ValidateChatIDTyped("--chat-ids", "bad")
|
||||
return err
|
||||
}(), "--chat-ids")
|
||||
assertValidationParam(t, func() error {
|
||||
_, err := ValidateUserIDTyped("--creator-ids", "bad")
|
||||
return err
|
||||
}(), "--creator-ids")
|
||||
}
|
||||
|
||||
func TestRejectDangerousCharsTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
err := RejectDangerousCharsTyped("--query", "bad\x01")
|
||||
validationErr := assertValidationParam(t, err, "--query")
|
||||
if !strings.Contains(validationErr.Message, "control character") {
|
||||
t.Fatalf("Message = %q, want control character", validationErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInputStatErrorTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
cause := &fileio.PathValidationError{Err: errors.New("outside cwd")}
|
||||
err := WrapInputStatErrorTyped(cause)
|
||||
validationErr := assertValidationParam(t, err, "")
|
||||
if !strings.Contains(validationErr.Message, "unsafe file path") {
|
||||
t.Fatalf("Message = %q, want unsafe file path", validationErr.Message)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("expected errors.Is(fileio.ErrPathValidation) to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
|
||||
pathErr := &fileio.PathValidationError{Err: errors.New("outside cwd")}
|
||||
assertValidationParam(t, WrapSaveErrorTyped(pathErr), "")
|
||||
|
||||
mkdirErr := &fileio.MkdirError{Err: errors.New("permission denied")}
|
||||
err := WrapSaveErrorTyped(mkdirErr)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtLeastOne(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -246,3 +371,20 @@ func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
|
||||
t.Fatalf("expected no error for non-existent path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping
|
||||
// path is rejected with a typed validation error and a safe path passes.
|
||||
func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
workDir := t.TempDir()
|
||||
chdirForTest(t, workDir)
|
||||
|
||||
if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
assertValidationParam(t, ValidateSafePathTyped(&localfileio.LocalFileIO{}, "evil_out"), "")
|
||||
|
||||
if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
|
||||
t.Fatalf("expected no error for safe path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
122
shortcuts/drive/drive_cover.go
Normal file
122
shortcuts/drive/drive_cover.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DriveCover = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+cover",
|
||||
Description: "List or download stable cover presets for a Drive file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "spec", Desc: "cover preset: default | icon | grid | small | middle | big | square"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list built-in cover specs without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded cover"},
|
||||
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if err := validateDrivePreviewMode(runtime.Str("spec"), runtime.Bool("list-only"), runtime.Str("output"), "spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDrivePreviewIfExists(runtime.Str("if-exists")); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec := strings.TrimSpace(runtime.Str("spec")); spec != "" {
|
||||
if _, ok := findDriveCoverSpec(spec); !ok {
|
||||
return wrapDriveCoverUnavailable(spec)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
if runtime.Bool("list-only") {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List built-in cover specs (no API call)").
|
||||
Set("mode", "list").
|
||||
Set("file_token", fileToken).
|
||||
Set("candidates", buildDriveCoverListOutput(fileToken)["candidates"])
|
||||
}
|
||||
|
||||
spec, _ := findDriveCoverSpec(runtime.Str("spec"))
|
||||
params := buildDriveCoverDownloadParams(strings.TrimSpace(runtime.Str("version")), spec)
|
||||
dry := common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("Download selected cover preset directly via preview_download").
|
||||
Params(params).
|
||||
Set("file_token", fileToken).
|
||||
Set("selected_spec", spec.Name).
|
||||
Set("output", runtime.Str("output"))
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedSpec := strings.TrimSpace(runtime.Str("spec"))
|
||||
outputPath := runtime.Str("output")
|
||||
ifExists := runtime.Str("if-exists")
|
||||
|
||||
if runtime.Bool("list-only") {
|
||||
runtime.Out(buildDriveCoverListOutput(fileToken), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
spec, ok := findDriveCoverSpec(requestedSpec)
|
||||
if !ok {
|
||||
return wrapDriveCoverUnavailable(requestedSpec)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover %s for file %s\n", spec.Name, common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, buildDriveCoverDownloadParams(version, spec), outputPath, ifExists, spec.FallbackExt)
|
||||
if err != nil {
|
||||
return wrapDriveCoverDownloadError(err, spec.Name)
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_spec"] = spec.Name
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// wrapDriveCoverDownloadError reclassifies preview_download HTTP 404 responses
|
||||
// on the +cover path as a failed precondition on --spec, because the Drive
|
||||
// shortcut contract documents 404 as "this file has no artifact for that cover
|
||||
// preset" rather than a transient transport failure.
|
||||
func wrapDriveCoverDownloadError(err error, requestedSpec string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Code != http.StatusNotFound {
|
||||
return err
|
||||
}
|
||||
hint := fmt.Sprintf(
|
||||
"This may mean no artifact exists for --spec %q, or that the file token/version is invalid. Verify the inputs, or rerun with `lark-cli drive +cover --file-token <file-token> --list-only`. Available cover specs: %s",
|
||||
requestedSpec,
|
||||
strings.Join(availableDriveCoverSpecs(), ", "),
|
||||
)
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"preview_download returned HTTP 404 for --spec %q",
|
||||
requestedSpec,
|
||||
).WithParam("--spec").WithCode(problem.Code).WithLogID(problem.LogID).WithHint(hint).WithCause(err)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
89
shortcuts/drive/drive_errors.go
Normal file
89
shortcuts/drive/drive_errors.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
118
shortcuts/drive/drive_preview.go
Normal file
118
shortcuts/drive/drive_preview.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var DrivePreview = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+preview",
|
||||
Description: "List or download available preview artifacts for a Drive file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded preview"},
|
||||
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if err := validateDrivePreviewMode(runtime.Str("type"), runtime.Bool("list-only"), runtime.Str("output"), "type"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDrivePreviewIfExists(runtime.Str("if-exists"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
}
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/:file_token/preview_result").
|
||||
Desc("[1] Fetch preview candidates for a Drive file").
|
||||
Set("file_token", fileToken)
|
||||
if len(body) > 0 {
|
||||
dry.Body(body)
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
return dry.Set("mode", "list")
|
||||
}
|
||||
downloadParams := map[string]interface{}{
|
||||
"preview_type": "<selected type_code from preview_result>",
|
||||
}
|
||||
if version != "" {
|
||||
downloadParams["version"] = version
|
||||
} else {
|
||||
downloadParams["version"] = "<resolved version from preview_result>"
|
||||
}
|
||||
return dry.
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
|
||||
Params(downloadParams).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", runtime.Str("type")).
|
||||
Set("output", runtime.Str("output"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedType := strings.TrimSpace(runtime.Str("type"))
|
||||
outputPath := runtime.Str("output")
|
||||
ifExists := runtime.Str("if-exists")
|
||||
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
|
||||
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
runtime.Out(buildDrivePreviewListOutput(fileToken, candidates), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
candidate, ok := selectDrivePreviewCandidate(candidates, requestedType)
|
||||
if !ok {
|
||||
return wrapDrivePreviewUnavailable(fileToken, requestedType, candidates, "")
|
||||
}
|
||||
if !candidate.Downloadable {
|
||||
return wrapDrivePreviewNotReady(fileToken, requestedType, candidate)
|
||||
}
|
||||
|
||||
downloadVersion := version
|
||||
if downloadVersion == "" {
|
||||
downloadVersion = versionString(data["version"])
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading preview %s for file %s\n", candidate.Type, common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, candidate.TypeCode, downloadVersion, outputPath, ifExists, drivePreviewFallbackExt(candidate.Type))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_type"] = candidate.Type
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
813
shortcuts/drive/drive_preview_common.go
Normal file
813
shortcuts/drive/drive_preview_common.go
Normal file
@@ -0,0 +1,813 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
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/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
drivePreviewIfExistsError = "error"
|
||||
drivePreviewIfExistsOverwrite = "overwrite"
|
||||
drivePreviewIfExistsRename = "rename"
|
||||
)
|
||||
|
||||
type drivePreviewCandidate struct {
|
||||
Type string
|
||||
TypeCode string
|
||||
TypeName string
|
||||
Label string
|
||||
Status string
|
||||
StatusCode string
|
||||
Downloadable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
type driveCoverSpec struct {
|
||||
Name string
|
||||
Label string
|
||||
Description string
|
||||
PreviewType string
|
||||
BusType string
|
||||
Platform string
|
||||
Width int
|
||||
Height int
|
||||
Policy string
|
||||
FallbackExt string
|
||||
}
|
||||
|
||||
type driveExtensionResolution struct {
|
||||
Ext string
|
||||
Source string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type drivePreviewTypeMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Type string
|
||||
Label string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
type drivePreviewStatusMeta struct {
|
||||
Code string
|
||||
Name string
|
||||
Reason string
|
||||
Downloadable bool
|
||||
}
|
||||
|
||||
var drivePreviewMimeToExt = map[string]string{
|
||||
"application/json": ".json",
|
||||
"application/msword": ".doc",
|
||||
"application/pdf": ".pdf",
|
||||
"application/xml": ".xml",
|
||||
"application/zip": ".zip",
|
||||
"image/bmp": ".bmp",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/plain": ".txt",
|
||||
"text/xml": ".xml",
|
||||
"video/mp4": ".mp4",
|
||||
"application/octet-stream": "",
|
||||
}
|
||||
|
||||
var drivePreviewTypes = []drivePreviewTypeMeta{
|
||||
{Code: "0", Name: "PDF", Type: "pdf", Label: "PDF Preview"},
|
||||
{Code: "1", Name: "PNG", Type: "png", Label: "PNG Preview", Aliases: []string{"image"}},
|
||||
{Code: "2", Name: "PAGES", Type: "pages", Label: "Paged Preview"},
|
||||
{Code: "3", Name: "VIDEO", Type: "video", Label: "Video Preview"},
|
||||
{Code: "4", Name: "MP4_360P", Type: "mp4_360p", Label: "MP4 360P Preview"},
|
||||
{Code: "5", Name: "MP4_480P", Type: "mp4_480p", Label: "MP4 480P Preview"},
|
||||
{Code: "6", Name: "MP4_720P", Type: "mp4_720p", Label: "MP4 720P Preview"},
|
||||
{Code: "7", Name: "JPG", Type: "jpg", Label: "JPG Preview", Aliases: []string{"image"}},
|
||||
{Code: "8", Name: "HTML", Type: "html", Label: "HTML Preview"},
|
||||
{Code: "9", Name: "PDF_LIN", Type: "pdf_lin", Label: "Linearized PDF Preview"},
|
||||
{Code: "10", Name: "XOD", Type: "xod", Label: "XOD Preview"},
|
||||
{Code: "11", Name: "JPG_LIN", Type: "jpg_lin", Label: "Linearized JPG Preview", Aliases: []string{"image"}},
|
||||
{Code: "12", Name: "PNG_LIN", Type: "png_lin", Label: "Linearized PNG Preview", Aliases: []string{"image"}},
|
||||
{Code: "13", Name: "ARCHIVE", Type: "archive", Label: "Archive Preview"},
|
||||
{Code: "14", Name: "TEXT", Type: "text", Label: "Text Preview"},
|
||||
{Code: "15", Name: "PDF_PART", Type: "pdf_part", Label: "Partial PDF Preview"},
|
||||
{Code: "16", Name: "SOURCE_FILE", Type: "source_file", Label: "Source File", Aliases: []string{"source"}},
|
||||
{Code: "17", Name: "VIDEO_META", Type: "video_meta", Label: "Video Metadata"},
|
||||
{Code: "18", Name: "WPS", Type: "wps", Label: "WPS Preview"},
|
||||
{Code: "19", Name: "SPLIT_PNG", Type: "split_png", Label: "Split PNG Preview", Aliases: []string{"image"}},
|
||||
{Code: "20", Name: "MEDIA_RESULT", Type: "media_result", Label: "Media Result"},
|
||||
{Code: "21", Name: "MIME", Type: "mime", Label: "MIME Type"},
|
||||
{Code: "22", Name: "SPILT_IMG_TXT", Type: "spilt_img_txt", Label: "Split Image Text"},
|
||||
{Code: "23", Name: "MP4_1080P", Type: "mp4_1080p", Label: "MP4 1080P Preview"},
|
||||
{Code: "24", Name: "IMAGE_META", Type: "image_meta", Label: "Image Metadata"},
|
||||
{Code: "25", Name: "DOC_PART", Type: "doc_part", Label: "Document Part"},
|
||||
{Code: "26", Name: "WATERMARK_PDF", Type: "watermark_pdf", Label: "Watermarked PDF Preview"},
|
||||
{Code: "27", Name: "FILE_WATERMARK", Type: "file_watermark", Label: "File Watermark"},
|
||||
}
|
||||
|
||||
var drivePreviewStatuses = []drivePreviewStatusMeta{
|
||||
{Code: "0", Name: "READY", Downloadable: true},
|
||||
{Code: "1", Name: "PROCESSING", Reason: "Preview is still processing."},
|
||||
{Code: "2", Name: "FAILED", Reason: "Preview generation failed."},
|
||||
{Code: "3", Name: "FAILED_NOT_RETRY", Reason: "Preview generation failed and will not retry."},
|
||||
{Code: "4", Name: "INVALID_EXTENTION", Reason: "File extension is invalid for this preview type."},
|
||||
{Code: "5", Name: "FILE_TOO_LARGE", Reason: "File is too large for preview generation."},
|
||||
{Code: "6", Name: "EMPTY_FILE", Reason: "File is empty."},
|
||||
{Code: "7", Name: "NO_SUPPORT", Reason: "Preview is not supported for this file."},
|
||||
{Code: "8", Name: "INVALID_PREVIEW_TYPE", Reason: "Preview type is invalid."},
|
||||
{Code: "9", Name: "NEED_PASSWORD", Reason: "Preview requires a password."},
|
||||
{Code: "10", Name: "FILE_INVALID", Reason: "File is invalid."},
|
||||
{Code: "11", Name: "TOO_MANY_PAGES", Reason: "File has too many pages for preview."},
|
||||
{Code: "1001", Name: "ARCHIVE_INVALID_FORMAT", Reason: "Archive format is invalid."},
|
||||
{Code: "1002", Name: "ARCHIVE_TOO_MANY_NODES", Reason: "Archive contains too many nodes."},
|
||||
{Code: "1003", Name: "ARCHIVE_TOO_MANY_NODES_PER_DIR", Reason: "Archive directory contains too many nodes."},
|
||||
{Code: "1004", Name: "THIRD_ENC_NO_PERMISSION", Reason: "No permission for third-party encrypted file."},
|
||||
{Code: "1006", Name: "NOT_SUPPORT_DECRYPT_THIRD_ENC_FILE", Reason: "Third-party encrypted file cannot be decrypted for preview."},
|
||||
}
|
||||
|
||||
var drivePreviewTypeByCode = func() map[string]drivePreviewTypeMeta {
|
||||
out := make(map[string]drivePreviewTypeMeta, len(drivePreviewTypes))
|
||||
for _, meta := range drivePreviewTypes {
|
||||
out[meta.Code] = meta
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
var drivePreviewStatusByCode = func() map[string]drivePreviewStatusMeta {
|
||||
out := make(map[string]drivePreviewStatusMeta, len(drivePreviewStatuses))
|
||||
for _, meta := range drivePreviewStatuses {
|
||||
out[meta.Code] = meta
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
var driveCoverSpecs = []driveCoverSpec{
|
||||
{
|
||||
Name: "default",
|
||||
Label: "Default Cover",
|
||||
Description: "Standard large cover (1280x1280).",
|
||||
PreviewType: "1",
|
||||
BusType: "cover",
|
||||
Platform: "pc",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "icon",
|
||||
Label: "Icon",
|
||||
Description: "Small list icon (120x120).",
|
||||
PreviewType: "1",
|
||||
BusType: "icon",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "grid",
|
||||
Label: "Grid Cover",
|
||||
Description: "Grid/card stream cover (360x360).",
|
||||
PreviewType: "1",
|
||||
BusType: "grid",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "small",
|
||||
Label: "Small Graph",
|
||||
Description: "PC small graph cover (480x480).",
|
||||
PreviewType: "1",
|
||||
BusType: "small_graph",
|
||||
Platform: "pc",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "middle",
|
||||
Label: "Middle Cover",
|
||||
Description: "Medium-sized cover (720x720).",
|
||||
PreviewType: "1",
|
||||
BusType: "middle",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "big",
|
||||
Label: "Big Cover",
|
||||
Description: "Large mobile-oriented cover (850x850).",
|
||||
PreviewType: "1",
|
||||
BusType: "big",
|
||||
Platform: "mobile",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
{
|
||||
Name: "square",
|
||||
Label: "Square Cover",
|
||||
Description: "Square-cropped grid cover (360x360).",
|
||||
PreviewType: "1",
|
||||
Width: 360,
|
||||
Height: 360,
|
||||
Policy: "near",
|
||||
FallbackExt: ".png",
|
||||
},
|
||||
}
|
||||
|
||||
// validateDrivePreviewMode checks the required flag combinations for list and
|
||||
// download modes.
|
||||
func validateDrivePreviewMode(selected string, listOnly bool, outputPath, flagName string) error {
|
||||
selected = strings.TrimSpace(selected)
|
||||
outputPath = strings.TrimSpace(outputPath)
|
||||
selectedFlag := "--" + flagName
|
||||
if listOnly {
|
||||
if selected != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be combined with --list-only", selectedFlag).WithParam(selectedFlag)
|
||||
}
|
||||
if outputPath != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --list-only").WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if selected == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "either --list-only or %s is required", selectedFlag).WithParam(selectedFlag)
|
||||
}
|
||||
if outputPath == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output is required when %s is set", selectedFlag).WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDrivePreviewIfExists validates the accepted overwrite policy values.
|
||||
func validateDrivePreviewIfExists(policy string) error {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "", drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename:
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", policy).WithParam("--if-exists")
|
||||
}
|
||||
}
|
||||
|
||||
// fetchDrivePreviewCandidates loads preview_result data and normalizes the
|
||||
// returned candidate list.
|
||||
func fetchDrivePreviewCandidates(runtime *common.RuntimeContext, fileToken string, body map[string]interface{}) (map[string]interface{}, []drivePreviewCandidate, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_result", validate.EncodePathSegment(fileToken)),
|
||||
nil,
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, normalizeDrivePreviewCandidates(data), nil
|
||||
}
|
||||
|
||||
// normalizeDrivePreviewCandidates converts preview_result items into internal
|
||||
// candidate records with stable type and status metadata.
|
||||
func normalizeDrivePreviewCandidates(data map[string]interface{}) []drivePreviewCandidate {
|
||||
items := common.GetSlice(data, "preview_results")
|
||||
candidates := make([]drivePreviewCandidate, 0, len(items))
|
||||
for _, item := range items {
|
||||
raw, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeCode := firstString(raw, "preview_type", "type_code", "type")
|
||||
statusCode := firstString(raw, "preview_status", "status_code", "status")
|
||||
candidate := drivePreviewCandidate{
|
||||
TypeCode: typeCode,
|
||||
StatusCode: statusCode,
|
||||
Reason: strings.TrimSpace(firstString(raw, "reason", "status_msg", "message", "msg", "detail")),
|
||||
}
|
||||
applyDrivePreviewTypeMeta(&candidate)
|
||||
applyDrivePreviewStatusMeta(&candidate)
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// selectDrivePreviewCandidate matches a requested preview type or alias against
|
||||
// the available candidates.
|
||||
func selectDrivePreviewCandidate(candidates []drivePreviewCandidate, requested string) (drivePreviewCandidate, bool) {
|
||||
requested = normalizeDrivePreviewRequest(requested)
|
||||
if requested == "" {
|
||||
return drivePreviewCandidate{}, false
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if requested == candidate.Type || requested == strings.ToLower(candidate.TypeName) || requested == strings.ToLower(strings.TrimSpace(candidate.TypeCode)) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
|
||||
var firstAliasMatch drivePreviewCandidate
|
||||
hasAliasMatch := false
|
||||
for _, candidate := range candidates {
|
||||
if !slices.Contains(previewAliasesForCandidate(candidate), requested) {
|
||||
continue
|
||||
}
|
||||
if candidate.Downloadable {
|
||||
return candidate, true
|
||||
}
|
||||
if !hasAliasMatch {
|
||||
firstAliasMatch = candidate
|
||||
hasAliasMatch = true
|
||||
}
|
||||
}
|
||||
if hasAliasMatch {
|
||||
return firstAliasMatch, true
|
||||
}
|
||||
return drivePreviewCandidate{}, false
|
||||
}
|
||||
|
||||
// buildDrivePreviewListOutput formats preview candidates for --list-only
|
||||
// responses.
|
||||
func buildDrivePreviewListOutput(fileToken string, candidates []drivePreviewCandidate) map[string]interface{} {
|
||||
items := make([]map[string]interface{}, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
item := map[string]interface{}{
|
||||
"type": candidate.Type,
|
||||
"type_code": candidate.TypeCode,
|
||||
"label": candidate.Label,
|
||||
"status": candidate.Status,
|
||||
"status_code": candidate.StatusCode,
|
||||
"downloadable": candidate.Downloadable,
|
||||
}
|
||||
if candidate.Reason != "" {
|
||||
item["reason"] = candidate.Reason
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"mode": "list",
|
||||
"file_token": fileToken,
|
||||
"candidates": items,
|
||||
}
|
||||
if len(items) > 0 {
|
||||
out["next_action"] = "select one candidate and rerun with --type plus --output"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildDriveCoverListOutput formats the built-in cover specs for --list-only
|
||||
// responses.
|
||||
func buildDriveCoverListOutput(fileToken string) map[string]interface{} {
|
||||
items := make([]map[string]interface{}, 0, len(driveCoverSpecs))
|
||||
for _, spec := range driveCoverSpecs {
|
||||
item := map[string]interface{}{
|
||||
"spec": spec.Name,
|
||||
"label": spec.Label,
|
||||
}
|
||||
if spec.Description != "" {
|
||||
item["description"] = spec.Description
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"mode": "list",
|
||||
"file_token": fileToken,
|
||||
"candidates": items,
|
||||
"next_action": "select one spec and rerun with --spec plus --output",
|
||||
}
|
||||
}
|
||||
|
||||
// findDriveCoverSpec resolves a cover spec by its user-facing name.
|
||||
func findDriveCoverSpec(name string) (driveCoverSpec, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
for _, spec := range driveCoverSpecs {
|
||||
if spec.Name == name {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return driveCoverSpec{}, false
|
||||
}
|
||||
|
||||
// buildDriveCoverDownloadParams translates a cover spec into preview_download
|
||||
// query parameters.
|
||||
func buildDriveCoverDownloadParams(version string, spec driveCoverSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"preview_type": spec.PreviewType,
|
||||
}
|
||||
if strings.TrimSpace(spec.BusType) != "" {
|
||||
params["bus_type"] = spec.BusType
|
||||
}
|
||||
if strings.TrimSpace(spec.Platform) != "" {
|
||||
params["platform"] = spec.Platform
|
||||
}
|
||||
if spec.Width > 0 {
|
||||
params["width"] = spec.Width
|
||||
}
|
||||
if spec.Height > 0 {
|
||||
params["height"] = spec.Height
|
||||
}
|
||||
if strings.TrimSpace(spec.Policy) != "" {
|
||||
params["policy"] = spec.Policy
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
params["version"] = version
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// downloadDrivePreviewArtifact downloads a preview artifact for a single
|
||||
// preview_type value.
|
||||
func downloadDrivePreviewArtifact(ctx context.Context, runtime *common.RuntimeContext, fileToken, previewType, version, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
|
||||
query := map[string]interface{}{
|
||||
"preview_type": previewType,
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
query["version"] = version
|
||||
}
|
||||
return downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, query, outputPath, ifExists, fallbackExt)
|
||||
}
|
||||
|
||||
// downloadDrivePreviewArtifactWithParams downloads a preview artifact using the
|
||||
// provided preview_download query parameters and writes it to the local path.
|
||||
func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common.RuntimeContext, fileToken string, query map[string]interface{}, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams, len(query))
|
||||
for key, value := range query {
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
queryParams[key] = []string{text}
|
||||
}
|
||||
|
||||
apiReq := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: queryParams,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, wrapDriveNetworkErr(err, "preview download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, driveSaveError(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
||||
if savedPath == "" {
|
||||
savedPath = finalPath
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"output_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
"status": "READY",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
|
||||
// inference and the selected collision policy.
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
|
||||
switch ifExists {
|
||||
case "", drivePreviewIfExistsError:
|
||||
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --if-exists overwrite or rename)", finalPath).WithParam("--output")
|
||||
} else if !errors.Is(statErr, fs.ErrNotExist) {
|
||||
return "", nil, errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", finalPath, statErr).WithCause(statErr)
|
||||
}
|
||||
return finalPath, resolution, nil
|
||||
case drivePreviewIfExistsOverwrite:
|
||||
return finalPath, resolution, nil
|
||||
case drivePreviewIfExistsRename:
|
||||
renamed, err := nextAvailableDrivePreviewPath(runtime.FileIO(), finalPath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(renamed); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
return renamed, resolution, nil
|
||||
default:
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", ifExists).WithParam("--if-exists")
|
||||
}
|
||||
}
|
||||
|
||||
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
|
||||
// target output path.
|
||||
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
|
||||
if _, err := fio.Stat(path); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
ext := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(filepath.Base(path), ext)
|
||||
for i := 1; i < 10000; i++ {
|
||||
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
|
||||
if _, err := fio.Stat(candidate); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return candidate, nil
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access candidate output path %s: %s", candidate, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot allocate a unique output path for %s", path)
|
||||
}
|
||||
|
||||
// autoAppendDrivePreviewExtension appends an inferred extension when the user
|
||||
// did not provide one explicitly.
|
||||
func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fallbackExt string) (string, *driveExtensionResolution) {
|
||||
if drivePreviewHasExplicitExtension(outputPath) {
|
||||
return outputPath, nil
|
||||
}
|
||||
normalizedPath := outputPath
|
||||
if filepath.Ext(outputPath) == "." {
|
||||
normalizedPath = strings.TrimSuffix(outputPath, ".")
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if fallbackExt != "" {
|
||||
return normalizedPath + fallbackExt, &driveExtensionResolution{
|
||||
Ext: fallbackExt,
|
||||
Source: "fallback",
|
||||
Detail: "default fallback",
|
||||
}
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
// drivePreviewHasExplicitExtension reports whether the path already ends with a
|
||||
// usable filename extension.
|
||||
func drivePreviewHasExplicitExtension(path string) bool {
|
||||
ext := filepath.Ext(path)
|
||||
return ext != "" && ext != "."
|
||||
}
|
||||
|
||||
// drivePreviewExtensionByContentType maps a response Content-Type header to a
|
||||
// file extension when possible.
|
||||
func drivePreviewExtensionByContentType(contentType string) *driveExtensionResolution {
|
||||
if contentType == "" {
|
||||
return nil
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
}
|
||||
if ext, ok := drivePreviewMimeToExt[strings.ToLower(mediaType)]; ok && ext != "" {
|
||||
return &driveExtensionResolution{
|
||||
Ext: ext,
|
||||
Source: "Content-Type",
|
||||
Detail: contentType,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePreviewExtensionByContentDisposition extracts an extension from the
|
||||
// response filename metadata.
|
||||
func drivePreviewExtensionByContentDisposition(header http.Header) *driveExtensionResolution {
|
||||
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
|
||||
if filename == "" {
|
||||
return nil
|
||||
}
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" || ext == "." {
|
||||
return nil
|
||||
}
|
||||
return &driveExtensionResolution{
|
||||
Ext: ext,
|
||||
Source: "Content-Disposition",
|
||||
Detail: filename,
|
||||
}
|
||||
}
|
||||
|
||||
// drivePreviewFallbackExt returns the default extension for known preview type
|
||||
// aliases when headers do not provide one.
|
||||
func drivePreviewFallbackExt(alias string) string {
|
||||
switch normalizeDrivePreviewRequest(alias) {
|
||||
case "pdf":
|
||||
return ".pdf"
|
||||
case "html":
|
||||
return ".html"
|
||||
case "text":
|
||||
return ".txt"
|
||||
case "png", "png_lin", "split_png":
|
||||
return ".png"
|
||||
case "jpg", "jpg_lin":
|
||||
return ".jpg"
|
||||
case "source", "source_file":
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// applyDrivePreviewTypeMeta fills normalized type metadata from the preview
|
||||
// type code.
|
||||
func applyDrivePreviewTypeMeta(candidate *drivePreviewCandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
|
||||
candidate.Type = meta.Type
|
||||
candidate.TypeName = meta.Name
|
||||
candidate.Label = meta.Label
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(candidate.TypeCode)
|
||||
if code == "" {
|
||||
candidate.Type = "unknown"
|
||||
candidate.TypeName = "UNKNOWN"
|
||||
candidate.Label = "Unknown Preview Type"
|
||||
return
|
||||
}
|
||||
candidate.Type = "unknown_" + code
|
||||
candidate.TypeName = "UNKNOWN"
|
||||
candidate.Label = fmt.Sprintf("Unknown Preview Type %s", code)
|
||||
}
|
||||
|
||||
// applyDrivePreviewStatusMeta fills normalized status metadata from the preview
|
||||
// status code.
|
||||
func applyDrivePreviewStatusMeta(candidate *drivePreviewCandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
if meta, ok := drivePreviewStatusByCode[candidate.StatusCode]; ok {
|
||||
candidate.Status = meta.Name
|
||||
candidate.Downloadable = meta.Downloadable
|
||||
if candidate.Reason == "" && !meta.Downloadable {
|
||||
candidate.Reason = meta.Reason
|
||||
}
|
||||
if meta.Downloadable {
|
||||
candidate.Reason = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
candidate.Status = "UNKNOWN"
|
||||
candidate.Downloadable = false
|
||||
if candidate.Reason == "" {
|
||||
if strings.TrimSpace(candidate.StatusCode) == "" {
|
||||
candidate.Reason = "Preview status is missing."
|
||||
} else {
|
||||
candidate.Reason = fmt.Sprintf("Unknown preview status %s.", candidate.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDrivePreviewRequest canonicalizes user input for preview type
|
||||
// matching.
|
||||
func normalizeDrivePreviewRequest(requested string) string {
|
||||
requested = strings.ToLower(strings.TrimSpace(requested))
|
||||
requested = strings.ReplaceAll(requested, "-", "_")
|
||||
requested = strings.ReplaceAll(requested, " ", "_")
|
||||
return requested
|
||||
}
|
||||
|
||||
// previewAliasesForCandidate returns configured aliases for a preview
|
||||
// candidate's type code.
|
||||
func previewAliasesForCandidate(candidate drivePreviewCandidate) []string {
|
||||
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
|
||||
return meta.Aliases
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstString returns the first non-empty string-like value from the provided
|
||||
// keys.
|
||||
func firstString(m map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return t
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if s := strings.TrimSpace(t.String()); s != "" {
|
||||
return s
|
||||
}
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// versionString normalizes version fields from heterogeneous API payload types.
|
||||
func versionString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// availableDrivePreviewTypes lists unique normalized preview type names from
|
||||
// the candidate set.
|
||||
func availableDrivePreviewTypes(candidates []drivePreviewCandidate) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
name := strings.TrimSpace(candidate.Type)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// availableDriveCoverSpecs lists the supported built-in cover spec names.
|
||||
func availableDriveCoverSpecs() []string {
|
||||
out := make([]string, 0, len(driveCoverSpecs))
|
||||
for _, spec := range driveCoverSpecs {
|
||||
out = append(out, spec.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wrapDrivePreviewUnavailable builds a validation error for an unsupported
|
||||
// preview selection.
|
||||
func wrapDrivePreviewUnavailable(fileToken, requested string, candidates []drivePreviewCandidate, reason string) error {
|
||||
available := availableDrivePreviewTypes(candidates)
|
||||
if reason == "" {
|
||||
reason = fmt.Sprintf("requested preview type %q is not available for file %s", requested, fileToken)
|
||||
}
|
||||
hint := "rerun with --list-only to inspect available preview types"
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available preview types: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// wrapDrivePreviewNotReady builds an actionable error for a preview candidate
|
||||
// that exists but is not yet downloadable.
|
||||
func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePreviewCandidate) error {
|
||||
reason := candidate.Reason
|
||||
if reason == "" {
|
||||
reason = fmt.Sprintf("preview type %q is not downloadable yet (status=%s)", requested, candidate.Status)
|
||||
}
|
||||
hint := fmt.Sprintf("rerun `lark-cli drive +preview --file-token %s --list-only` to inspect current candidate status", fileToken)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
|
||||
// spec.
|
||||
func wrapDriveCoverUnavailable(requested string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --spec %q", requested).
|
||||
WithHint("available cover specs: %s", strings.Join(availableDriveCoverSpecs(), ", ")).
|
||||
WithParam("--spec")
|
||||
}
|
||||
926
shortcuts/drive/drive_preview_test.go
Normal file
926
shortcuts/drive/drive_preview_test.go
Normal file
@@ -0,0 +1,926 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestDrivePreviewListOnlyNormalizesCandidates verifies list mode output is
|
||||
// normalized from preview_result payloads.
|
||||
func TestDrivePreviewListOnlyNormalizesCandidates(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 0, "preview_status": 0},
|
||||
{"preview_type": 14, "preview_status": 1},
|
||||
{"preview_type": 16, "preview_status": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["mode"]; got != "list" {
|
||||
t.Fatalf("mode=%v, want list", got)
|
||||
}
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 3 {
|
||||
t.Fatalf("len(candidates)=%d, want 3", len(candidates))
|
||||
}
|
||||
first, _ := candidates[0].(map[string]interface{})
|
||||
if got := first["type"]; got != "pdf" {
|
||||
t.Fatalf("candidate[0].type=%v, want pdf", got)
|
||||
}
|
||||
if got := first["type_code"]; got != "0" {
|
||||
t.Fatalf("candidate[0].type_code=%v, want 0", got)
|
||||
}
|
||||
if got := first["status"]; got != "READY" {
|
||||
t.Fatalf("candidate[0].status=%v, want READY", got)
|
||||
}
|
||||
if got := first["downloadable"]; got != true {
|
||||
t.Fatalf("candidate[0].downloadable=%v, want true", got)
|
||||
}
|
||||
second, _ := candidates[1].(map[string]interface{})
|
||||
if got := second["status_code"]; got != "1" {
|
||||
t.Fatalf("candidate[1].status_code=%v, want 1", got)
|
||||
}
|
||||
if got := second["reason"]; got != "Preview is still processing." {
|
||||
t.Fatalf("candidate[1].reason=%v, want processing reason", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy verifies preview
|
||||
// downloads use the resolved type and rename collision handling.
|
||||
func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": 7,
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 0, "preview_status": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_download?preview_type=0",
|
||||
Status: 200,
|
||||
Body: []byte("%PDF-1.7"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "pdf",
|
||||
"--output", "report",
|
||||
"--if-exists", "rename",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["selected_type"]; got != "pdf" {
|
||||
t.Fatalf("selected_type=%v, want pdf", got)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "report (1).pdf")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected preview artifact at %q: %v", wantPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
|
||||
// return an actionable validation error.
|
||||
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 8, "preview_status": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "pdf",
|
||||
"--output", "report",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected unavailable type error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `requested preview type "pdf" is not available`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch verifies alias
|
||||
// selection prefers a downloadable candidate over an earlier unavailable one.
|
||||
func TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch(t *testing.T) {
|
||||
candidate, ok := selectDrivePreviewCandidate([]drivePreviewCandidate{
|
||||
{Type: "png", TypeCode: "1", Downloadable: false, Status: "PROCESSING"},
|
||||
{Type: "jpg", TypeCode: "7", Downloadable: true, Status: "READY"},
|
||||
}, "image")
|
||||
if !ok {
|
||||
t.Fatal("expected alias match, got none")
|
||||
}
|
||||
if candidate.Type != "jpg" {
|
||||
t.Fatalf("selected candidate=%q, want jpg", candidate.Type)
|
||||
}
|
||||
if !candidate.Downloadable {
|
||||
t.Fatalf("selected candidate should be downloadable: %+v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverListOnlyUsesStaticSpecs verifies cover list mode returns the
|
||||
// built-in spec catalog without calling APIs.
|
||||
func TestDriveCoverListOnlyUsesStaticSpecs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != len(driveCoverSpecs) {
|
||||
t.Fatalf("len(candidates)=%d, want %d", len(candidates), len(driveCoverSpecs))
|
||||
}
|
||||
last, _ := candidates[len(candidates)-1].(map[string]interface{})
|
||||
if got := last["spec"]; got != "square" {
|
||||
t.Fatalf("last spec=%v, want square", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType verifies cover
|
||||
// downloads send the expected preview_download query mapping.
|
||||
func TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
|
||||
Status: 200,
|
||||
Body: []byte("png-data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/png"},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "square",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["selected_spec"]; got != "square" {
|
||||
t.Fatalf("selected_spec=%v, want square", got)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "cover.png")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
if _, err := os.Stat(wantPath); err != nil {
|
||||
t.Fatalf("expected cover file at %q: %v", wantPath, err)
|
||||
}
|
||||
if got := capturedQuery.Get("preview_type"); got != "1" {
|
||||
t.Fatalf("preview_type=%q, want 1", got)
|
||||
}
|
||||
if got := capturedQuery.Get("bus_type"); got != "" {
|
||||
t.Fatalf("bus_type=%q, want empty for square crop flow", got)
|
||||
}
|
||||
if got := capturedQuery.Get("platform"); got != "" {
|
||||
t.Fatalf("platform=%q, want empty when using default platform", got)
|
||||
}
|
||||
if got := capturedQuery.Get("width"); got != "360" {
|
||||
t.Fatalf("width=%q, want 360", got)
|
||||
}
|
||||
if got := capturedQuery.Get("height"); got != "360" {
|
||||
t.Fatalf("height=%q, want 360", got)
|
||||
}
|
||||
if got := capturedQuery.Get("policy"); got != "near" {
|
||||
t.Fatalf("policy=%q, want near", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDownload404ReturnsFailedPrecondition verifies the +cover path
|
||||
// reclassifies preview_download HTTP 404 as a non-retryable spec/state issue.
|
||||
func TestDriveCoverDownload404ReturnsFailedPrecondition(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
|
||||
Status: http.StatusNotFound,
|
||||
Body: []byte(`{"code":404,"msg":"no artifact"}`),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "square",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected cover 404 error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if validationErr.Param != "--spec" {
|
||||
t.Fatalf("param=%q, want --spec", validationErr.Param)
|
||||
}
|
||||
if validationErr.Code != http.StatusNotFound {
|
||||
t.Fatalf("code=%d, want %d", validationErr.Code, http.StatusNotFound)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--list-only") {
|
||||
t.Fatalf("hint=%q, want --list-only guidance", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "file token/version is invalid") {
|
||||
t.Fatalf("hint=%q, want invalid file token/version guidance", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "available cover specs") && !strings.Contains(validationErr.Hint, "default, icon, grid") {
|
||||
t.Fatalf("hint=%q, want available cover specs guidance", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), `preview_download returned HTTP 404 for --spec "square"`) {
|
||||
t.Fatalf("message=%q, want neutral 404 message", validationErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// newDrivePreviewRuntime builds a shortcut runtime with preconfigured preview
|
||||
// and cover flags for DryRun and helper tests.
|
||||
func newDrivePreviewRuntime(t *testing.T, use string, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: use}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("spec", "", "")
|
||||
cmd.Flags().String("version", "", "")
|
||||
cmd.Flags().String("output", "", "")
|
||||
cmd.Flags().String("if-exists", drivePreviewIfExistsError, "")
|
||||
cmd.Flags().Bool("list-only", false, "")
|
||||
for name, value := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if !value {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, "true"); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
// decodeDryRunOutput marshals a DryRunAPI helper into a generic map for test
|
||||
// assertions.
|
||||
func decodeDryRunOutput(t *testing.T, dry *common.DryRunAPI) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunIncludesVersionAndMode verifies preview DryRun records
|
||||
// versioned request metadata in download mode.
|
||||
func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_preview",
|
||||
"type": "image",
|
||||
"version": "7",
|
||||
"output": "preview",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "download" {
|
||||
t.Fatalf("mode=%v, want download", got)
|
||||
}
|
||||
if got := data["requested_type"]; got != "image" {
|
||||
t.Fatalf("requested_type=%v, want image", got)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "POST" {
|
||||
t.Fatalf("method=%v, want POST", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_result" {
|
||||
t.Fatalf("url=%v, want preview_result", got)
|
||||
}
|
||||
body, _ := call["body"].(map[string]interface{})
|
||||
if got := body["version"]; got != "7" {
|
||||
t.Fatalf("body.version=%v, want 7", got)
|
||||
}
|
||||
downloadCall, _ := api[1].(map[string]interface{})
|
||||
if got := downloadCall["method"]; got != "GET" {
|
||||
t.Fatalf("download method=%v, want GET", got)
|
||||
}
|
||||
if got := downloadCall["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_download" {
|
||||
t.Fatalf("download url=%v, want preview_download", got)
|
||||
}
|
||||
params, _ := downloadCall["params"].(map[string]interface{})
|
||||
if got := params["preview_type"]; got != "<selected type_code from preview_result>" {
|
||||
t.Fatalf("download params.preview_type=%v, want placeholder", got)
|
||||
}
|
||||
if got := params["version"]; got != "7" {
|
||||
t.Fatalf("download params.version=%v, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
|
||||
// omits the request body when no version is supplied.
|
||||
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_preview",
|
||||
}, map[string]bool{"list-only": true})
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "list" {
|
||||
t.Fatalf("mode=%v, want list", got)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if _, ok := call["body"]; ok {
|
||||
t.Fatalf("dry-run body should be omitted when version is empty: %#v", call)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion verifies
|
||||
// download-mode DryRun documents the second preview_download step even when the
|
||||
// final version is only known after preview_result resolves candidates.
|
||||
func TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_preview",
|
||||
"type": "pdf",
|
||||
"output": "preview",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
downloadCall, _ := api[1].(map[string]interface{})
|
||||
params, _ := downloadCall["params"].(map[string]interface{})
|
||||
if got := params["version"]; got != "<resolved version from preview_result>" {
|
||||
t.Fatalf("download params.version=%v, want resolved-version placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDryRunListAndDownload verifies cover DryRun output for both
|
||||
// list and download modes.
|
||||
func TestDriveCoverDryRunListAndDownload(t *testing.T) {
|
||||
listRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
|
||||
"file-token": "file_cover",
|
||||
}, map[string]bool{"list-only": true})
|
||||
listData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), listRuntime))
|
||||
if got := listData["mode"]; got != "list" {
|
||||
t.Fatalf("list mode=%v, want list", got)
|
||||
}
|
||||
if _, ok := listData["candidates"].([]interface{}); !ok {
|
||||
t.Fatalf("list candidates missing: %#v", listData)
|
||||
}
|
||||
|
||||
downloadRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
|
||||
"file-token": "file_cover",
|
||||
"spec": "square",
|
||||
"version": "3",
|
||||
"output": "cover",
|
||||
}, nil)
|
||||
downloadData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), downloadRuntime))
|
||||
if got := downloadData["selected_spec"]; got != "square" {
|
||||
t.Fatalf("selected_spec=%v, want square", got)
|
||||
}
|
||||
api, _ := downloadData["api"].([]interface{})
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["width"]; got != float64(360) {
|
||||
t.Fatalf("params.width=%v, want 360", got)
|
||||
}
|
||||
if got := params["policy"]; got != "near" {
|
||||
t.Fatalf("params.policy=%v, want near", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform verifies DryRun
|
||||
// params include version and built-in platform metadata for default covers.
|
||||
func TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
|
||||
"file-token": "file_cover",
|
||||
"spec": "default",
|
||||
"version": "5",
|
||||
"output": "cover",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["bus_type"]; got != "cover" {
|
||||
t.Fatalf("params.bus_type=%v, want cover", got)
|
||||
}
|
||||
if got := params["platform"]; got != "pc" {
|
||||
t.Fatalf("params.platform=%v, want pc", got)
|
||||
}
|
||||
if got := params["version"]; got != "5" {
|
||||
t.Fatalf("params.version=%v, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewValidationErrors verifies preview flag validation rejects
|
||||
// incomplete and conflicting argument combinations.
|
||||
func TestDrivePreviewValidationErrors(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "either --list-only or --type is required") {
|
||||
t.Fatalf("unexpected missing type error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--list-only",
|
||||
"--type", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--type cannot be combined with --list-only") {
|
||||
t.Fatalf("unexpected list-only conflict: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "pdf",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output is required when --type is set") {
|
||||
t.Fatalf("unexpected missing output error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewNotReadyReturnsFailedPrecondition verifies a known but
|
||||
// unready preview candidate returns a failed-precondition error.
|
||||
func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"preview_results": []map[string]interface{}{
|
||||
{"preview_type": 1, "preview_status": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_preview",
|
||||
"--type", "image",
|
||||
"--output", "preview",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected not-ready error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if validationErr.Param != "--type" {
|
||||
t.Fatalf("param=%q, want --type", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--list-only") {
|
||||
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
|
||||
// validation error with available alternatives.
|
||||
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "poster",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid spec error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--spec" {
|
||||
t.Fatalf("param=%q, want --spec", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "available cover specs") {
|
||||
t.Fatalf("hint=%q, want available specs", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverValidationErrors verifies cover flag validation rejects
|
||||
// incomplete and conflicting argument combinations.
|
||||
func TestDriveCoverValidationErrors(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--spec", "default",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--output is required when --spec is set") {
|
||||
t.Fatalf("unexpected missing output error: %v", err)
|
||||
}
|
||||
|
||||
err = mountAndRunDrive(t, DriveCover, []string{
|
||||
"+cover",
|
||||
"--file-token", "file_cover",
|
||||
"--list-only",
|
||||
"--spec", "default",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "--spec cannot be combined with --list-only") {
|
||||
t.Fatalf("unexpected list-only conflict: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewCommonHelpers exercises helper branches for extension
|
||||
// inference and fallback extension mapping.
|
||||
func TestDrivePreviewCommonHelpers(t *testing.T) {
|
||||
if got := drivePreviewFallbackExt("pdf"); got != ".pdf" {
|
||||
t.Fatalf("fallbackExt(pdf)=%q, want .pdf", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("html"); got != ".html" {
|
||||
t.Fatalf("fallbackExt(html)=%q, want .html", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("text"); got != ".txt" {
|
||||
t.Fatalf("fallbackExt(text)=%q, want .txt", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("jpg"); got != ".jpg" {
|
||||
t.Fatalf("fallbackExt(jpg)=%q, want .jpg", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("jpg_lin"); got != ".jpg" {
|
||||
t.Fatalf("fallbackExt(jpg_lin)=%q, want .jpg", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("split_png"); got != ".png" {
|
||||
t.Fatalf("fallbackExt(split_png)=%q, want .png", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("source"); got != "" {
|
||||
t.Fatalf("fallbackExt(source)=%q, want empty", got)
|
||||
}
|
||||
if got := drivePreviewFallbackExt("unknown"); got != "" {
|
||||
t.Fatalf("fallbackExt(unknown)=%q, want empty", got)
|
||||
}
|
||||
specs := availableDriveCoverSpecs()
|
||||
if len(specs) == 0 || specs[len(specs)-1] != "square" {
|
||||
t.Fatalf("availableDriveCoverSpecs()=%v, want square included", specs)
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
header.Set("Content-Disposition", `attachment; filename="preview.pdf"`)
|
||||
resolution := drivePreviewExtensionByContentDisposition(header)
|
||||
if resolution == nil || resolution.Ext != ".pdf" {
|
||||
t.Fatalf("content disposition resolution=%+v, want .pdf", resolution)
|
||||
}
|
||||
header.Set("Content-Disposition", `attachment; filename="preview"`)
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
t.Fatalf("content disposition without ext should be nil: %+v", resolution)
|
||||
}
|
||||
|
||||
path, fallback := autoAppendDrivePreviewExtension("cover", http.Header{}, ".png")
|
||||
if path != "cover.png" || fallback == nil || fallback.Source != "fallback" {
|
||||
t.Fatalf("fallback append = (%q, %+v), want cover.png with fallback source", path, fallback)
|
||||
}
|
||||
path, fallback = autoAppendDrivePreviewExtension("cover.", http.Header{}, ".png")
|
||||
if path != "cover.png" || fallback == nil {
|
||||
t.Fatalf("trailing-dot append = (%q, %+v), want cover.png", path, fallback)
|
||||
}
|
||||
path, fallback = autoAppendDrivePreviewExtension("cover.pdf", http.Header{}, ".png")
|
||||
if path != "cover.pdf" || fallback != nil {
|
||||
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
|
||||
// and output path resolution helpers across rename and overwrite flows.
|
||||
func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
candidate := drivePreviewCandidate{TypeCode: "999", StatusCode: "", Reason: ""}
|
||||
applyDrivePreviewTypeMeta(&candidate)
|
||||
applyDrivePreviewStatusMeta(&candidate)
|
||||
if candidate.Type != "unknown_999" {
|
||||
t.Fatalf("candidate.Type=%q, want unknown_999", candidate.Type)
|
||||
}
|
||||
if candidate.Reason != "Preview status is missing." {
|
||||
t.Fatalf("candidate.Reason=%q, want missing-status reason", candidate.Reason)
|
||||
}
|
||||
|
||||
ready := drivePreviewCandidate{TypeCode: "1", StatusCode: "0"}
|
||||
applyDrivePreviewTypeMeta(&ready)
|
||||
applyDrivePreviewStatusMeta(&ready)
|
||||
if ready.Type != "png" || !ready.Downloadable {
|
||||
t.Fatalf("ready candidate=%+v, want downloadable png", ready)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "preview.pdf"), []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
header := http.Header{}
|
||||
header.Set("Content-Type", "application/pdf")
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(renamed, "preview (1).pdf") {
|
||||
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
|
||||
}
|
||||
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid if-exists error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--if-exists" {
|
||||
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
|
||||
}
|
||||
|
||||
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
|
||||
}
|
||||
if unusedPath != "fresh.pdf" {
|
||||
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
|
||||
}
|
||||
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(overwritten, "preview.pdf") {
|
||||
t.Fatalf("overwritten=%q, want preview.pdf suffix", overwritten)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
|
||||
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
runtimeWithStatErr.Factory = f
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
|
||||
if err == nil {
|
||||
t.Fatal("expected stat permission error, got nil")
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("Subtype=%q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
|
||||
type drivePreviewTestStringer string
|
||||
|
||||
type statErrorProvider struct {
|
||||
inner fileio.Provider
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *statErrorProvider) Name() string { return "stat-error" }
|
||||
|
||||
func (p *statErrorProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
return &statErrorFileIO{inner: p.inner.ResolveFileIO(ctx), err: p.err}
|
||||
}
|
||||
|
||||
type statErrorFileIO struct {
|
||||
inner fileio.FileIO
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *statErrorFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) }
|
||||
|
||||
func (f *statErrorFileIO) Stat(string) (fileio.FileInfo, error) { return nil, f.err }
|
||||
|
||||
func (f *statErrorFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) }
|
||||
|
||||
func (f *statErrorFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
||||
return f.inner.Save(path, opts, body)
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer for scalar helper tests.
|
||||
func (s drivePreviewTestStringer) String() string { return string(s) }
|
||||
|
||||
// TestDrivePreviewScalarHelpers verifies scalar coercion helpers normalize
|
||||
// mixed API field types into strings.
|
||||
func TestDrivePreviewScalarHelpers(t *testing.T) {
|
||||
got := firstString(map[string]interface{}{
|
||||
"blank": " ",
|
||||
"number": float64(7),
|
||||
"flag": true,
|
||||
"named": drivePreviewTestStringer(" named "),
|
||||
"integer": int64(9),
|
||||
}, "blank", "named", "number")
|
||||
if got != "named" {
|
||||
t.Fatalf("firstString()=%q, want named", got)
|
||||
}
|
||||
|
||||
if got := firstString(map[string]interface{}{"flag": true}, "flag"); got != "true" {
|
||||
t.Fatalf("firstString(bool)=%q, want true", got)
|
||||
}
|
||||
if got := firstString(map[string]interface{}{"integer": int64(9)}, "integer"); got != "9" {
|
||||
t.Fatalf("firstString(int64)=%q, want 9", got)
|
||||
}
|
||||
|
||||
if got := versionString(" 42 "); got != "42" {
|
||||
t.Fatalf("versionString(string)=%q, want 42", got)
|
||||
}
|
||||
if got := versionString(float64(8)); got != "8" {
|
||||
t.Fatalf("versionString(float64)=%q, want 8", got)
|
||||
}
|
||||
if got := versionString(int64(11)); got != "11" {
|
||||
t.Fatalf("versionString(int64)=%q, want 11", got)
|
||||
}
|
||||
if got := versionString(struct{}{}); got != "" {
|
||||
t.Fatalf("versionString(struct)=%q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewAliasAndAvailabilityHelpers verifies alias lookup,
|
||||
// normalization, and available-type de-duplication helpers.
|
||||
func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
|
||||
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
|
||||
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
|
||||
}
|
||||
|
||||
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
|
||||
if len(aliases) == 0 || aliases[0] != "image" {
|
||||
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
|
||||
}
|
||||
if got := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "999"}); got != nil {
|
||||
t.Fatalf("previewAliasesForCandidate(unknown)=%v, want nil", got)
|
||||
}
|
||||
|
||||
types := availableDrivePreviewTypes([]drivePreviewCandidate{
|
||||
{Type: "pdf"},
|
||||
{Type: "pdf"},
|
||||
{Type: " jpg "},
|
||||
{Type: ""},
|
||||
})
|
||||
if len(types) != 2 || types[0] != "pdf" || types[1] != "jpg" {
|
||||
t.Fatalf("availableDrivePreviewTypes()=%v, want [pdf jpg]", types)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewUnavailableHintAndContentTypeFallback verifies unavailable
|
||||
// preview errors and content-type fallback extension inference.
|
||||
func TestDrivePreviewUnavailableHintAndContentTypeFallback(t *testing.T) {
|
||||
err := wrapDrivePreviewUnavailable("file_preview", "html", []drivePreviewCandidate{
|
||||
{Type: "pdf"},
|
||||
{Type: "jpg"},
|
||||
}, "")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "available preview types: pdf, jpg") {
|
||||
t.Fatalf("hint=%q, want available preview types", validationErr.Hint)
|
||||
}
|
||||
|
||||
err = wrapDrivePreviewUnavailable("file_preview", "html", nil, fmt.Sprintf("custom reason for %s", "html"))
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--list-only") {
|
||||
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
|
||||
}
|
||||
|
||||
resolution := drivePreviewExtensionByContentType("text/plain; charset=utf-8")
|
||||
if resolution == nil || resolution.Ext != ".txt" {
|
||||
t.Fatalf("drivePreviewExtensionByContentType()=%+v, want .txt", resolution)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user