mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
15 Commits
feat/sidec
...
feat/svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6459966c | ||
|
|
37986331f4 | ||
|
|
3bbf823ce9 | ||
|
|
e43a57ce14 | ||
|
|
edf7ad81dd | ||
|
|
d98ef05dc7 | ||
|
|
24ce3ec151 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef |
@@ -65,10 +65,23 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
|
||||
# still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -94,6 +107,23 @@ linters:
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on drive ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Drive has migrated its
|
||||
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
|
||||
# this prevents reintroduction. Other domains still use the shared
|
||||
# helpers (migrated globally in a later phase), so this is drive-scoped.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use the typed errs.NewXxxError builders or the drive-local
|
||||
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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 +989,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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)
|
||||
}
|
||||
21
cmd/root.go
21
cmd/root.go
@@ -48,20 +48,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.
|
||||
@@ -255,6 +241,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
||||
// URL), all outgoing requests are rewritten to the sidecar address. The
|
||||
// interceptor strips placeholder credentials, injects proxy headers, and
|
||||
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
||||
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
||||
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -47,17 +46,15 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
@@ -133,13 +130,8 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
||||
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
||||
scheme := i.sidecarScheme
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
req.URL.Scheme = scheme
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
|
||||
@@ -7,13 +7,11 @@ package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
@@ -99,54 +97,6 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
||||
// rewrites the request to https://<remote-host>, while still preserving the
|
||||
// original target and signing the request.
|
||||
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if req.URL.Scheme != "https" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
||||
}
|
||||
if req.URL.Host != "sidecar.mycorp.com" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
||||
}
|
||||
// Original target still preserved for the sidecar to forward upstream.
|
||||
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
// Request is still signed.
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
||||
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
||||
// https, never silently downgrading a remote sidecar to plaintext http.
|
||||
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
||||
t.Setenv(envvars.CliProxyKey, "key")
|
||||
|
||||
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
||||
si, ok := ic.(*Interceptor)
|
||||
if !ok || si == nil {
|
||||
t.Fatalf("expected *Interceptor, got %T", ic)
|
||||
}
|
||||
if si.sidecarScheme != "https" {
|
||||
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
||||
}
|
||||
if si.sidecarHost != "sidecar.mycorp.com" {
|
||||
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// Content safety scanning mode
|
||||
|
||||
@@ -129,6 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
@@ -231,6 +232,22 @@ func ConfigHint(subtype errs.Subtype) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||
func APIHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeConflict:
|
||||
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "operate on source and target within the same tenant and region/unit"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "operate on source and target within the same brand environment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -270,6 +270,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
|
||||
@@ -306,6 +306,39 @@ func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
146
lint/errscontract/rule_no_legacy_envelope_literal.go
Normal file
146
lint/errscontract/rule_no_legacy_envelope_literal.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
|
||||
// to the typed errs.* taxonomy. On these paths, constructing a legacy
|
||||
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/drive/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
|
||||
// name (default or alias) this path is bound to in each file, so an aliased
|
||||
// import cannot bypass the check.
|
||||
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
|
||||
|
||||
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
|
||||
// output.ExitError / output.ErrDetail composite literals on migrated paths.
|
||||
// forbidigo can ban identifiers but not composite literals, so this AST rule
|
||||
// covers the gap left after a path is migrated to typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
|
||||
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
|
||||
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
|
||||
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Resolve the local name(s) bound to the legacy output import path. A file
|
||||
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
|
||||
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
|
||||
// as bare unqualified idents.
|
||||
localNames, dotImported := resolveLegacyOutputNames(file)
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_envelope_literal",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(lit.Pos()).Line,
|
||||
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
|
||||
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
|
||||
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// isMigratedEnvelopePath reports whether path falls under any migrated path
|
||||
// prefix in migratedEnvelopePaths.
|
||||
func isMigratedEnvelopePath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedEnvelopePaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveLegacyOutputNames walks the file's import declarations and returns the
|
||||
// set of local names bound to legacyOutputImportPath, plus whether the path was
|
||||
// dot-imported. Default imports bind the package's own name ("output"); aliased
|
||||
// imports bind the alias; dot-imports bind names into the file scope.
|
||||
func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != legacyOutputImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
// Default import: local name is the package name "output".
|
||||
names["output"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
// Blank import cannot reference the types; ignore.
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
|
||||
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
|
||||
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
|
||||
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
|
||||
// local names for the legacy output import, and — when the package was
|
||||
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
|
||||
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
|
||||
if sel, ok := expr.(*ast.SelectorExpr); ok {
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok || sel.Sel == nil {
|
||||
return "", false
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return "", false
|
||||
}
|
||||
return matchLegacyEnvelopeName(sel.Sel.Name)
|
||||
}
|
||||
if dotImported {
|
||||
if ident, ok := expr.(*ast.Ident); ok {
|
||||
return matchLegacyEnvelopeName(ident.Name)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// matchLegacyEnvelopeName returns the name when it is one of the legacy
|
||||
// envelope type names.
|
||||
func matchLegacyEnvelopeName(name string) (string, bool) {
|
||||
switch name {
|
||||
case "ExitError", "ErrDetail":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
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,287 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
|
||||
t.Errorf("message must name the offending call: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
|
||||
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() *output.ErrDetail {
|
||||
return &output.ErrDetail{Code: 7}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ErrDetail") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
|
||||
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
|
||||
// Same offending literal, but outside the migrated path set → not flagged.
|
||||
src := `package other
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("_test.go file should be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
|
||||
// import of internal/output cannot bypass the rule: the qualifier is resolved
|
||||
// from the import declaration, not matched against the literal string "output".
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &legacy.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
|
||||
// regression where resolving by import path accidentally drops the default
|
||||
// (non-aliased) `output` case.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
|
||||
// a CallExpr, not a composite literal — even under an alias it must not fire.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return legacy.ErrBare(legacy.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
|
||||
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
|
||||
// the composite literal.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
|
||||
// selector on an unrelated package (not the legacy output import path) must not
|
||||
// trigger a false positive.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "example.com/other/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("unrelated package selector must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
|
||||
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed wrapper call must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
|
||||
// RawAPI / DoAPI return the raw response for the caller to classify and do
|
||||
// not emit a legacy envelope — they are not banned.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
|
||||
_, err := runtime.DoAPI(nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("test files must be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckNoRegistrar(rel, string(src))...)
|
||||
all = append(all, CheckAdHocSubtype(rel, string(src))...)
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
|
||||
// Typed-error invariants — self-scope to errs/ + classify.go.
|
||||
all = append(all, CheckNilSafeError(rel, string(src))...)
|
||||
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.45",
|
||||
"version": "1.0.46",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -71,6 +71,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 +122,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,
|
||||
|
||||
@@ -974,7 +974,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 +990,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{
|
||||
|
||||
@@ -254,35 +254,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 +611,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`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -885,11 +889,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 +904,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) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -233,6 +234,133 @@ func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interfa
|
||||
return HandleApiResult(result, err, "API call failed")
|
||||
}
|
||||
|
||||
// CallAPITyped is the typed-only replacement for CallAPI: it performs the same
|
||||
// SDK request (buildRequest → APIClient.DoAPI → DoSDKRequest, identical
|
||||
// transport and query model to CallAPI) and returns the "data" object, but
|
||||
// classifies failures into typed errs.* errors via errclass.BuildAPIError.
|
||||
//
|
||||
// A transport / auth error from the client boundary is already typed and passes
|
||||
// through unchanged; a non-zero API response code is classified into a typed
|
||||
// error carrying subtype / code / log_id. Unlike CallAPI it never emits a legacy
|
||||
// output.ExitError envelope, and never downgrades a typed network/auth error.
|
||||
//
|
||||
// It lifts x-tt-logid from the response header (which the body-only parse drops)
|
||||
// so log_id surfaces on the typed error even when the server returns it only in
|
||||
// the header.
|
||||
func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
return nil, typedOrInternal(err)
|
||||
}
|
||||
resp, err := ac.DoAPI(ctx.ctx, ctx.buildRequest(method, url, params, data))
|
||||
if err != nil {
|
||||
return nil, typedOrInternal(err)
|
||||
}
|
||||
return ctx.ClassifyAPIResponse(resp)
|
||||
}
|
||||
|
||||
// ClassifyAPIResponse turns a raw *larkcore.ApiResp into the "data" object or a
|
||||
// typed errs.* error. It is the shared response classifier for typed API paths
|
||||
// — used by CallAPITyped and by callers that drive the request themselves
|
||||
// (e.g. file upload via DoAPI). It:
|
||||
//
|
||||
// 1. parses the JSON body; an unparseable body on an HTTP error status (a
|
||||
// gateway 5xx text/html page, an empty body, a missing Content-Type) is
|
||||
// classified by status — 5xx → retryable network/server_error, 404 →
|
||||
// not_found, other 4xx → api error — not a misleading invalid-response
|
||||
// internal error;
|
||||
// 2. rejects a top-level non-object JSON ([], null, scalar) as an
|
||||
// invalid-response internal error — never a silent success ack;
|
||||
// 3. lifts x-tt-logid from the response header onto the typed error so log_id
|
||||
// surfaces even when the body omits it;
|
||||
// 4. classifies a non-zero API code via errclass.BuildAPIError, and treats any
|
||||
// HTTP error status that parsed to code==0 as a status error.
|
||||
//
|
||||
// The success "data" object is returned untouched. On a non-zero API code the
|
||||
// data is returned alongside the typed error, since the response can still
|
||||
// carry fields a caller needs on failure (e.g. the file_token an overwrite
|
||||
// returned, for token-stability handling).
|
||||
func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) {
|
||||
logID, _ := logIDFromHeader(resp)["log_id"].(string)
|
||||
|
||||
result, parseErr := client.ParseJSONResponse(resp)
|
||||
if parseErr != nil {
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, httpStatusError(resp.StatusCode, resp.RawBody, logID)
|
||||
}
|
||||
return nil, client.WrapJSONResponseParseError(parseErr, resp.RawBody)
|
||||
}
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
e := errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
|
||||
if logID != "" {
|
||||
e = e.WithLogID(logID)
|
||||
}
|
||||
return nil, e
|
||||
}
|
||||
if logID != "" {
|
||||
if _, present := resultMap["log_id"]; !present {
|
||||
resultMap["log_id"] = logID
|
||||
}
|
||||
}
|
||||
out, _ := resultMap["data"].(map[string]interface{})
|
||||
if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil {
|
||||
return out, apiErr
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return out, httpStatusError(resp.StatusCode, resp.RawBody, logID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// httpStatusError classifies an HTTP error status whose body is not a usable
|
||||
// API envelope: 5xx → retryable network/server_error, 404 → not_found, other
|
||||
// 4xx → api error. The x-tt-logid (when present) is attached for diagnosis.
|
||||
func httpStatusError(status int, rawBody []byte, logID string) error {
|
||||
body := TruncateStr(strings.TrimSpace(string(rawBody)), 500)
|
||||
if status >= 500 {
|
||||
e := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", status, body).WithCode(status).WithRetryable()
|
||||
if logID != "" {
|
||||
e = e.WithLogID(logID)
|
||||
}
|
||||
return e
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
e := errs.NewAPIError(subtype, "HTTP %d: %s", status, body).WithCode(status)
|
||||
if logID != "" {
|
||||
e = e.WithLogID(logID)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// typedOrInternal passes an already-typed errs.* error through unchanged and
|
||||
// lifts a still-untyped one to a typed internal error, so CallAPITyped never
|
||||
// returns a bare/legacy error.
|
||||
func typedOrInternal(err error) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
|
||||
// APIClassifyContext builds the errclass.ClassifyContext for the running command
|
||||
// from the runtime config and resolved identity.
|
||||
func (ctx *RuntimeContext) APIClassifyContext() errclass.ClassifyContext {
|
||||
larkCmd := ""
|
||||
if ctx.Cmd != nil {
|
||||
larkCmd = strings.TrimPrefix(ctx.Cmd.CommandPath(), "lark ")
|
||||
}
|
||||
return errclass.ClassifyContext{
|
||||
Brand: string(ctx.Config.Brand),
|
||||
AppID: ctx.Config.AppID,
|
||||
Identity: string(ctx.As()),
|
||||
LarkCmd: larkCmd,
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response.
|
||||
// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options.
|
||||
//
|
||||
@@ -552,28 +680,47 @@ func (ctx *RuntimeContext) ValidatePath(path string) error {
|
||||
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
ctx.emit(data, meta, false)
|
||||
ctx.emit(data, meta, false, true)
|
||||
}
|
||||
|
||||
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
|
||||
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
|
||||
// that should be preserved as-is in JSON output.
|
||||
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
||||
ctx.emit(data, meta, true)
|
||||
ctx.emit(data, meta, true, true)
|
||||
}
|
||||
|
||||
// emit is the shared success-path emitter. raw=true disables JSON HTML escaping so
|
||||
// XML/HTML payloads (e.g. DocxXML bodies) are preserved verbatim; otherwise behavior
|
||||
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
|
||||
// and returns the partial-failure exit signal. Use it for batch operations
|
||||
// where some items failed but the per-item outcomes are the primary output:
|
||||
// the full result (summary + per-item statuses) stays machine-readable on
|
||||
// stdout, the process exits non-zero, and nothing is written to stderr.
|
||||
//
|
||||
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
|
||||
// envelope's ok field honestly reports failure instead of a misleading
|
||||
// ok:true, and the exit signal is distinct from the predicate-only ErrBare.
|
||||
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
|
||||
ctx.emit(data, meta, false, false)
|
||||
if ctx.outputErr != nil {
|
||||
return ctx.outputErr
|
||||
}
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
|
||||
// emit is the shared stdout envelope emitter; ok sets the envelope's ok field
|
||||
// (true for success, false for a partial-failure result). raw=true disables JSON
|
||||
// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved
|
||||
// verbatim; otherwise behavior
|
||||
// is identical — content-safety scanning and race-safe first-error capture via
|
||||
// outputErrOnce apply in both modes.
|
||||
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw bool) {
|
||||
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) {
|
||||
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
||||
return
|
||||
}
|
||||
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
@@ -1029,6 +1176,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
|
||||
@@ -96,3 +96,76 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+read",
|
||||
Description: "test read",
|
||||
HasFormat: true,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if flag := cmd.Flags().Lookup("json"); flag == nil {
|
||||
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+update",
|
||||
Description: "test update",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "json", Desc: "body JSON object", Required: true},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+update"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
// --json flag exists (from custom Flags), but should be the string type, not bool.
|
||||
flag := cmd.Flags().Lookup("json")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --json flag from custom Flags")
|
||||
}
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+write",
|
||||
Description: "test write",
|
||||
HasFormat: false,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+write"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
// --format is now registered for all shortcuts (regardless of HasFormat),
|
||||
// so --json should also be present.
|
||||
if flag := cmd.Flags().Lookup("json"); flag == nil {
|
||||
t.Fatal("expected --json flag to be registered even when HasFormat is false")
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -478,9 +478,9 @@ func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
|
||||
// already a directory locally. SafeOutputPath would refuse to overwrite
|
||||
// the directory at write time, but if --if-exists=skip silently swallows
|
||||
// the collision the caller sees "skipped" and assumes the mirror is
|
||||
// in sync. The fix surfaces it as a structured `partial_failure`
|
||||
// ExitError (non-zero exit + items[] in error.detail) under both skip
|
||||
// and overwrite policies so callers can react via exit code.
|
||||
// in sync. The fix surfaces it as a partial-failure (ok:false items[] payload
|
||||
// on stdout + non-zero exit) under both skip and overwrite policies so callers
|
||||
// can react via exit code.
|
||||
func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
|
||||
for _, policy := range []string{"overwrite", "skip"} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
@@ -515,8 +515,8 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
|
||||
"--if-exists", policy,
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
detail := assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullDetail(t, detail)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("[%s] summary.failed = %v, want 1", policy, got)
|
||||
}
|
||||
@@ -529,9 +529,6 @@ func TestDrivePullSurfacesDirectoryFileMirrorConflict(t *testing.T) {
|
||||
if msg, _ := items[0]["error"].(string); !strings.Contains(msg, "is a directory") {
|
||||
t.Errorf("[%s] error message should mention the directory conflict, got: %q", policy, msg)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("[%s] stdout should be empty on partial_failure, got: %s", policy, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -900,8 +897,8 @@ func TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder(t *testing
|
||||
|
||||
// TestDrivePullDeleteLocalCountsFailureInSummary pins the contract that
|
||||
// a failed delete shows up in summary.failed (not just in items[]) AND
|
||||
// surfaces as a partial_failure ExitError so callers can detect the
|
||||
// half-synced state via exit code. Before the fix, the delete_failed
|
||||
// surfaces as a non-zero exit (partial-failure signal) so callers can detect
|
||||
// the half-synced state via exit code. Before the fix, the delete_failed
|
||||
// branches appended an item but left `failed` at zero AND returned nil,
|
||||
// so the JSON envelope reported `ok=true`+`exit=0` even when the mirror
|
||||
// was incomplete. Setup forces os.Remove to fail by making the file's
|
||||
@@ -947,8 +944,8 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
detail := assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullDetail(t, detail)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1 (delete_failed must increment failed)", got)
|
||||
}
|
||||
@@ -958,15 +955,12 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
|
||||
if len(items) != 1 || items[0]["action"] != "delete_failed" {
|
||||
t.Errorf("expected one items[] entry with action=delete_failed, got: %#v", items)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero pins the
|
||||
// gating contract for --delete-local: when the download pass produced
|
||||
// any failure, the delete walk MUST be skipped entirely and the command
|
||||
// MUST exit non-zero with type=partial_failure. The half-synced state
|
||||
// MUST exit non-zero via the partial-failure signal. The half-synced state
|
||||
// where some Drive files are missing locally AND some local-only files
|
||||
// have been removed is never observable.
|
||||
func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
|
||||
@@ -1014,12 +1008,12 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
exitErr := assertDrivePullPartialFailure(t, err)
|
||||
if !strings.Contains(exitErr.Detail.Message, "--delete-local was skipped") {
|
||||
t.Errorf("expected message to mention --delete-local skip, got: %q", exitErr.Detail.Message)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
if note := drivePullStdoutNote(t, stdout.Bytes()); !strings.Contains(note, "--delete-local was skipped") {
|
||||
t.Errorf("expected note to mention --delete-local skip, got: %q", note)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePullDetail(t, exitErr)
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
@@ -1036,9 +1030,6 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
|
||||
if _, statErr := os.Stat(stale); statErr != nil {
|
||||
t.Fatalf("stale.txt must survive when --delete-local is skipped after a download failure; stat err=%v", statErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout should be empty on partial_failure, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the
|
||||
@@ -1343,49 +1334,60 @@ func mustReadFile(t *testing.T, path, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertDrivePullPartialFailure asserts that err is the structured
|
||||
// partial_failure ExitError +pull returns when any item-level failure
|
||||
// happens, and returns the unwrapped *ExitError so the caller can drill
|
||||
// into Detail.Detail without re-doing the type assertion.
|
||||
func assertDrivePullPartialFailure(t *testing.T, err error) *output.ExitError {
|
||||
// assertDrivePullPartialFailure asserts that err is the typed partial-failure
|
||||
// exit signal +pull returns on any item-level failure. The structured
|
||||
// {summary, items, note} payload rides on stdout as an ok:false envelope via
|
||||
// runtime.OutPartialFailure (in alignment with +push/+sync), so this helper
|
||||
// only checks the exit-code signal; callers read the payload from stdout via
|
||||
// splitDrivePullStdout.
|
||||
func assertDrivePullPartialFailure(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure ExitError, got nil")
|
||||
t.Fatal("expected partial-failure exit signal, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI)
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail must be set on partial_failure")
|
||||
}
|
||||
if exitErr.Detail.Type != "partial_failure" {
|
||||
t.Errorf("error.type = %q, want partial_failure", exitErr.Detail.Type)
|
||||
}
|
||||
return exitErr
|
||||
}
|
||||
|
||||
// splitDrivePullDetail extracts the {summary, items[]} payload from the
|
||||
// ExitError detail. We round-trip through JSON so test assertions don't
|
||||
// depend on the concrete map types the production code happens to use.
|
||||
func splitDrivePullDetail(t *testing.T, exitErr *output.ExitError) (map[string]interface{}, []map[string]interface{}) {
|
||||
// splitDrivePullStdout extracts the {summary, items[]} payload from the
|
||||
// stdout envelope written by runtime.Out. We round-trip through JSON so test
|
||||
// assertions don't depend on the concrete map types the production code
|
||||
// happens to use.
|
||||
func splitDrivePullStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(exitErr.Detail.Detail)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal detail: %v", err)
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
var got struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal detail: %v\nraw=%s", err, string(raw))
|
||||
if envelope.Data.Summary == nil {
|
||||
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
||||
}
|
||||
if got.Summary == nil {
|
||||
t.Fatalf("error.detail missing summary; raw=%s", string(raw))
|
||||
}
|
||||
return got.Summary, got.Items
|
||||
return envelope.Data.Summary, envelope.Data.Items
|
||||
}
|
||||
|
||||
// drivePullStdoutNote extracts the partial-failure "note" guidance from the
|
||||
// stdout envelope. The human-readable note that used to live in the
|
||||
// partial_failure ExitError message now rides on stdout alongside the
|
||||
// summary + items payload.
|
||||
func drivePullStdoutNote(t *testing.T, stdout []byte) string {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Note string `json:"note"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
return envelope.Data.Note
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -112,26 +112,26 @@ var DrivePush = common.Shortcut{
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return driveInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
||||
}
|
||||
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
|
||||
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes")
|
||||
}
|
||||
// Conditional scope pre-check: when --delete-remote --yes is set, the
|
||||
// run will issue DELETE /open-apis/drive/v1/files/<token> after the
|
||||
@@ -185,11 +185,11 @@ var DrivePush = common.Shortcut{
|
||||
// FileIO.Open's SafeInputPath check still accepts.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
@@ -217,7 +217,7 @@ var DrivePush = common.Shortcut{
|
||||
// reruns.
|
||||
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
@@ -374,7 +374,7 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
payload := map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"uploaded": uploaded,
|
||||
"skipped": skipped,
|
||||
@@ -382,15 +382,15 @@ var DrivePush = common.Shortcut{
|
||||
"deleted_remote": deletedRemote,
|
||||
},
|
||||
"items": items,
|
||||
}, nil)
|
||||
// Bump the exit code on any item-level failure (upload, overwrite,
|
||||
// folder, or delete) so callers / scripts / agents can react. The
|
||||
// summary + items[] envelope was just written to stdout via Out(),
|
||||
// so ErrBare here only affects the exit code — the structured
|
||||
// per-item context is still in the stdout JSON.
|
||||
if failed > 0 {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
// On any item-level failure (upload, overwrite, folder, or delete) the
|
||||
// command reports a partial failure: the summary + per-item items[] stay
|
||||
// machine-readable on stdout (ok:false) and the process exits non-zero,
|
||||
// so callers / scripts / agents can react.
|
||||
if failed > 0 {
|
||||
return runtime.OutPartialFailure(payload, nil)
|
||||
}
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -466,7 +466,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
|
||||
}
|
||||
dirs := make([]string, 0, len(dirsSet))
|
||||
for d := range dirsSet {
|
||||
@@ -543,7 +543,7 @@ func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
}
|
||||
remoteFiles[rel] = chosen
|
||||
default:
|
||||
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
return nil, nil, nil, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
}
|
||||
}
|
||||
return remoteFiles, remoteFolders, fileGroups, nil
|
||||
@@ -567,7 +567,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_folder",
|
||||
nil,
|
||||
@@ -581,7 +581,7 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "create_folder for %q returned no folder token", relDir)
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir)
|
||||
}
|
||||
folderCache[relDir] = token
|
||||
return token, nil
|
||||
@@ -617,7 +617,7 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi
|
||||
func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) {
|
||||
f, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", "", common.WrapInputStatError(err)
|
||||
return "", "", driveInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -644,27 +644,22 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", output.ErrNetwork("upload failed: %v", err)
|
||||
return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
// Extract the token before the larkCode check: the backend can produce
|
||||
// a partial-success response (code != 0 alongside a non-empty
|
||||
// data.file_token) where bytes have already landed under that token.
|
||||
// Returning "" here would force the caller to fall back to
|
||||
// ClassifyAPIResponse returns the data even on a non-zero code, so the
|
||||
// token is available on a partial-success response (code != 0 alongside a
|
||||
// non-empty data.file_token) where bytes have already landed under that
|
||||
// token. Returning "" would force the caller to fall back to
|
||||
// entry.FileToken and silently lose the token Drive actually used,
|
||||
// defeating the overwrite-error token-stability handling in Execute.
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
token := common.GetString(data, "file_token")
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return token, "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
if err != nil {
|
||||
return token, "", err
|
||||
}
|
||||
if token == "" {
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
@@ -677,7 +672,7 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
|
||||
// deployed backend hasn't shipped the field yet we surface the gap
|
||||
// rather than report a phantom success — callers can downgrade to
|
||||
// --if-exists=skip in the meantime.
|
||||
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
return token, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
}
|
||||
return token, version, nil
|
||||
}
|
||||
@@ -692,7 +687,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
if existingToken != "" {
|
||||
prepareBody["file_token"] = existingToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -701,7 +696,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
blockSize := int64(common.GetFloat(prepareResult, "block_size"))
|
||||
blockNum := int(common.GetFloat(prepareResult, "block_num"))
|
||||
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error",
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
@@ -717,7 +712,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
// one Open + Close + path-validation per block).
|
||||
partFile, err := runtime.FileIO().Open(file.OpenPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return "", driveInputStatError(err)
|
||||
}
|
||||
defer partFile.Close()
|
||||
|
||||
@@ -744,21 +739,16 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
if errors.As(doErr, &exitErr) {
|
||||
return "", doErr
|
||||
}
|
||||
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
}
|
||||
|
||||
var partResult map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
}
|
||||
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
|
||||
msg, _ := partResult["msg"].(string)
|
||||
return "", output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
@@ -767,7 +757,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
token := common.GetString(finishResult, "file_token")
|
||||
if token == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -776,7 +766,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
// never reached here because --delete-remote only iterates the type=file
|
||||
// subset of the remote listing.
|
||||
func drivePushDeleteFile(_ context.Context, runtime *common.RuntimeContext, fileToken string) error {
|
||||
_, err := runtime.CallAPI(
|
||||
_, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
|
||||
map[string]interface{}{"type": driveTypeFile},
|
||||
|
||||
@@ -871,21 +871,19 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
|
||||
"--if-exists", "overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
// Item-level failures bump the exit code via output.ErrBare(ExitAPI),
|
||||
// preserving the structured items[] envelope on stdout. Older behavior
|
||||
// was to silently return nil; the assertion below pins the new contract.
|
||||
// Item-level failures report a partial failure: an ok:false items[]
|
||||
// envelope on stdout + a non-zero exit via the partial-failure signal.
|
||||
// Older behavior was to silently return nil; the assertion below pins
|
||||
// the new contract.
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail != nil {
|
||||
t.Errorf("ErrBare should carry no Detail (the items[] envelope already covered the per-item error), got: %#v", exitErr.Detail)
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
@@ -959,12 +957,19 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI from output.ExitError, got %T %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Partial failure reports an ok:false result envelope on stdout (not a
|
||||
// misleading ok:true) while still carrying BOTH the succeeded and failed
|
||||
// items — consistent with the pre-change payload. The failed side is
|
||||
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
|
||||
if !strings.Contains(out, `"ok": false`) {
|
||||
t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"failed": 1`) {
|
||||
t.Errorf("expected failed=1, got: %s", out)
|
||||
}
|
||||
@@ -1042,9 +1047,9 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI ExitError, got %v", err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
@@ -1065,7 +1070,7 @@ func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
|
||||
|
||||
// TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run
|
||||
// with no failures must NOT bump the exit code. Without this the
|
||||
// ErrBare-on-failure path could regress to "always non-zero" silently.
|
||||
// partial-failure path could regress to "always non-zero" silently.
|
||||
func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -219,13 +219,13 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
|
||||
// that depends on the combination of flag values.
|
||||
func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.Time) (map[string]interface{}, []string, error) {
|
||||
if spec.Mine && len(spec.CreatorIDs) > 0 {
|
||||
return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids")
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
|
||||
}
|
||||
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
|
||||
return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
|
||||
}
|
||||
if spec.Mine && userOpenID == "" {
|
||||
return nil, nil, output.ErrValidation("--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config")
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine")
|
||||
}
|
||||
|
||||
if err := validateDocTypes(spec.DocTypes); err != nil {
|
||||
@@ -337,7 +337,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("--page-size must be a number, got %q", raw)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be a number, got %q", raw).WithParam("--page-size")
|
||||
}
|
||||
if n <= 0 {
|
||||
return 15, nil
|
||||
@@ -355,23 +355,23 @@ func parseDriveSearchPageSize(raw string) (int, error) {
|
||||
func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
for _, id := range spec.CreatorIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return output.ErrValidation("--creator-ids %q: %s", id, err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
|
||||
}
|
||||
}
|
||||
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
|
||||
return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
|
||||
}
|
||||
for _, id := range spec.ChatIDs {
|
||||
if _, err := common.ValidateChatID(id); err != nil {
|
||||
return output.ErrValidation("--chat-ids %q: %s", id, err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
|
||||
}
|
||||
}
|
||||
if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs {
|
||||
return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
|
||||
}
|
||||
for _, id := range spec.SharerIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return output.ErrValidation("--sharer-ids %q: %s", id, err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -382,7 +382,7 @@ func validateDocTypes(values []string) error {
|
||||
// values are already upper-cased by readDriveSearchSpec; compare as-is
|
||||
// so the filter we emit to the server matches what we validated.
|
||||
if _, ok := driveSearchDocTypeSet[v]; !ok {
|
||||
return output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v).WithParam("--doc-types")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -417,13 +417,13 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error)
|
||||
}
|
||||
sinceUnix, err := parseTimeValue(spec.OpenedSince, now)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-since %q: %s", spec.OpenedSince, err).WithParam("--opened-since")
|
||||
}
|
||||
var untilUnix int64
|
||||
if spec.OpenedUntil != "" {
|
||||
untilUnix, err = parseTimeValue(spec.OpenedUntil, now)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-until %q: %s", spec.OpenedUntil, err).WithParam("--opened-until")
|
||||
}
|
||||
} else {
|
||||
untilUnix = now.Unix()
|
||||
@@ -440,7 +440,7 @@ func clampOpenedTimeWindow(spec *driveSearchSpec, now time.Time) (string, error)
|
||||
}
|
||||
maxSecs := int64(driveSearchMaxOpenedSpanDays) * 24 * 3600
|
||||
if spanSecs > maxSecs {
|
||||
return "", output.ErrValidation(
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--opened-* window spans %d days, exceeds the %d-day (1-year) maximum; narrow the range or run multiple queries",
|
||||
spanSecs/86400, driveSearchMaxOpenedSpanDays,
|
||||
)
|
||||
@@ -505,7 +505,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i
|
||||
if since != "" {
|
||||
unix, err := parseTimeValue(since, now)
|
||||
if err != nil {
|
||||
return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err)
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-since %q: %s", timeDimCLIName(key), since, err).WithParam(fmt.Sprintf("--%s-since", timeDimCLIName(key)))
|
||||
}
|
||||
if hourAggregated && unix%3600 != 0 {
|
||||
snapped := floorHour(unix)
|
||||
@@ -517,7 +517,7 @@ func buildTimeRangeFilter(key, since, until string, now time.Time) (map[string]i
|
||||
if until != "" {
|
||||
unix, err := parseTimeValue(until, now)
|
||||
if err != nil {
|
||||
return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err)
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-until %q: %s", timeDimCLIName(key), until, err).WithParam(fmt.Sprintf("--%s-until", timeDimCLIName(key)))
|
||||
}
|
||||
if hourAggregated && unix%3600 != 0 {
|
||||
snapped := ceilHour(unix)
|
||||
@@ -571,7 +571,7 @@ var driveSearchRelativeRe = regexp.MustCompile(`^(\d+)([dmy])$`)
|
||||
func parseTimeValue(input string, now time.Time) (int64, error) {
|
||||
s := strings.TrimSpace(input)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty value")
|
||||
return 0, fmt.Errorf("empty value") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
|
||||
}
|
||||
|
||||
if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil {
|
||||
@@ -616,34 +616,27 @@ func parseTimeValue(input string, now time.Time) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds")
|
||||
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
|
||||
}
|
||||
|
||||
func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
|
||||
if err != nil {
|
||||
return nil, enrichDriveSearchError(err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// enrichDriveSearchError adds a +search-specific hint for known opaque Lark
|
||||
// codes; other errors pass through unchanged.
|
||||
// enrichDriveSearchError adds a +search-specific hint for a known opaque Lark
|
||||
// code; other errors pass through unchanged. The hint is appended in place on
|
||||
// the typed Problem, preserving its category / subtype / code / log_id.
|
||||
func enrichDriveSearchError(err error) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != driveSearchErrUserNotVisible {
|
||||
return err
|
||||
}
|
||||
if exitErr.Detail.Code != driveSearchErrUserNotVisible {
|
||||
return err
|
||||
}
|
||||
detail := *exitErr.Detail
|
||||
detail.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &detail,
|
||||
Err: exitErr.Err,
|
||||
}
|
||||
p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
|
||||
return err
|
||||
}
|
||||
|
||||
func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -258,6 +260,19 @@ func TestValidateDriveSearchIDs(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "--creator-ids") {
|
||||
t.Fatalf("expected --creator-ids error, got: %v", err)
|
||||
}
|
||||
var vErr *errs.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if vErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if vErr.Param != "--creator-ids" {
|
||||
t.Fatalf("Param = %q, want --creator-ids", vErr.Param)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bad chat id format", func(t *testing.T) {
|
||||
@@ -625,51 +640,39 @@ func TestEnrichDriveSearchError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExitError without Detail passes through", func(t *testing.T) {
|
||||
t.Run("typed error with non-matching code passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
orig := &output.ExitError{Code: 1}
|
||||
if got := enrichDriveSearchError(orig); got != orig {
|
||||
t.Fatalf("ExitError without Detail should pass through unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExitError with non-matching code passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
orig := &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Code: 12345, Message: "other"},
|
||||
}
|
||||
orig := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(12345), "msg": "other"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
if got := enrichDriveSearchError(orig); got != orig {
|
||||
t.Fatalf("non-matching code should pass through unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) {
|
||||
t.Run("matching code decorates the typed error's hint in place", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
orig := &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{
|
||||
Code: driveSearchErrUserNotVisible,
|
||||
Message: "[99992351] user not visible",
|
||||
Hint: "",
|
||||
},
|
||||
}
|
||||
orig := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(driveSearchErrUserNotVisible), "msg": "[99992351] user not visible"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
// Terminal decoration of an upstream error: the hint is set in place on
|
||||
// the existing typed Problem and that same error is returned (no new
|
||||
// error is constructed).
|
||||
enriched := enrichDriveSearchError(orig)
|
||||
eErr, ok := enriched.(*output.ExitError)
|
||||
if enriched != orig {
|
||||
t.Fatal("should decorate and return the upstream error, not construct a new one")
|
||||
}
|
||||
p, ok := errs.ProblemOf(enriched)
|
||||
if !ok {
|
||||
t.Fatalf("expected *output.ExitError, got %T", enriched)
|
||||
t.Fatalf("expected a typed errs.* error, got %T", enriched)
|
||||
}
|
||||
if eErr == orig {
|
||||
t.Fatal("should return a new ExitError, not mutate the original")
|
||||
if !strings.Contains(p.Hint, "--creator-ids") {
|
||||
t.Fatalf("hint should mention --creator-ids, got %q", p.Hint)
|
||||
}
|
||||
if orig.Detail.Hint != "" {
|
||||
t.Fatal("original Detail.Hint must remain unchanged")
|
||||
}
|
||||
if !strings.Contains(eErr.Detail.Hint, "--creator-ids") {
|
||||
t.Fatalf("hint should mention --creator-ids, got %q", eErr.Detail.Hint)
|
||||
}
|
||||
if eErr.Detail.Message != orig.Detail.Message {
|
||||
t.Fatalf("Message should be preserved, got %q", eErr.Detail.Message)
|
||||
if p.Message != "[99992351] user not visible" {
|
||||
t.Fatalf("Message should be preserved, got %q", p.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -739,6 +742,18 @@ func TestBuildDriveSearchRequest(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "--mine") {
|
||||
t.Fatalf("expected exclusion error, got: %v", err)
|
||||
}
|
||||
// Mutual-exclusion error: typed validation, but no single attributable
|
||||
// flag, so Param stays empty.
|
||||
var vErr *errs.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if vErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if vErr.Param != "" {
|
||||
t.Fatalf("Param = %q, want empty for mutual-exclusion error", vErr.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 || pageSize > 10 {
|
||||
return output.ErrValidation("--page-size must be between 1 and 10")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 10").WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -47,7 +47,7 @@ var DriveSecureLabelList = common.Shortcut{
|
||||
Params(buildSecureLabelListParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPI("GET",
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
buildSecureLabelListParams(runtime),
|
||||
nil,
|
||||
@@ -95,7 +95,7 @@ var DriveSecureLabelUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
data, err := runtime.CallAPI("PATCH",
|
||||
data, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -75,27 +75,27 @@ var DriveStatus = common.Shortcut{
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
}
|
||||
// Path safety (absolute paths, traversal, symlink escape) is enforced
|
||||
// upfront by the framework helper so the error message references the
|
||||
// correct flag name; FileIO().Stat below would do the same check, but
|
||||
// surface --file in its hint.
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return driveInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
||||
}
|
||||
// Conditional scope pre-check: quick mode only compares local mtime with
|
||||
// Drive modified_time, so it must not be blocked on the download grant.
|
||||
@@ -144,11 +144,11 @@ var DriveStatus = common.Shortcut{
|
||||
// only possible under a Validate↔Execute race.
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
@@ -263,7 +263,7 @@ func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalF
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
@@ -276,12 +276,12 @@ func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Ti
|
||||
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatError(err)
|
||||
return "", driveInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, err)
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "hash %s: %s", path, err).WithCause(err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -292,12 +292,12 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
return "", wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, resp.Body); err != nil {
|
||||
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
return "", wrapDriveNetworkErr(err, "hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -822,12 +822,15 @@ func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected walkLocalForStatus() to fail for missing root")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured ExitError, got %T", err)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "io" {
|
||||
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "walk") {
|
||||
t.Fatalf("expected walk-related error, got: %v", err)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -72,23 +72,23 @@ var DriveSync = common.Shortcut{
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
if localDir == "" {
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return driveInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -118,15 +118,15 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
safeRoot, err := validate.SafeInputPath(localDir)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return output.ErrValidation("could not resolve cwd: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
||||
}
|
||||
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
|
||||
if err != nil {
|
||||
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir")
|
||||
}
|
||||
|
||||
// --- Phase 1: Compute diff (same logic as +status) ---
|
||||
@@ -176,18 +176,18 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
}
|
||||
if len(typeConflicts) > 0 {
|
||||
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
|
||||
}
|
||||
|
||||
// Build the exact remote-file views that later execution will use so the
|
||||
// diff phase classifies files against the same duplicate-resolution choice.
|
||||
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
return errs.WrapInternal(err)
|
||||
}
|
||||
|
||||
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
|
||||
@@ -240,43 +240,19 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
conflictResolutions := make(map[string]string, len(modified))
|
||||
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
|
||||
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--on-conflict=ask requires interactive stdin when modified files exist").WithParam("--on-conflict")
|
||||
}
|
||||
for _, entry := range modified {
|
||||
resolved := onConflict
|
||||
if resolved == driveSyncOnConflictAsk {
|
||||
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
|
||||
if err != nil {
|
||||
payload := map[string]interface{}{
|
||||
"detection": detection,
|
||||
"diff": map[string]interface{}{
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
"unchanged": emptyIfNil(unchanged),
|
||||
},
|
||||
"summary": map[string]interface{}{
|
||||
"pulled": 0,
|
||||
"pushed": 0,
|
||||
"skipped": 0,
|
||||
"failed": 1,
|
||||
},
|
||||
"items": []driveSyncItem{{
|
||||
RelPath: entry.RelPath,
|
||||
FileToken: entry.FileToken,
|
||||
Action: "failed",
|
||||
Direction: "conflict",
|
||||
Error: err.Error(),
|
||||
}},
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
// Phase-1 setup abort: no sync operation ran yet, so this
|
||||
// is not a batch partial-failure. driveSyncAskConflict
|
||||
// already returns a typed *errs.ValidationError; propagate
|
||||
// it unchanged rather than re-wrapping it as a synthetic
|
||||
// partial_failure payload.
|
||||
return err
|
||||
}
|
||||
}
|
||||
conflictResolutions[entry.RelPath] = resolved
|
||||
@@ -521,17 +497,12 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "partial_failure",
|
||||
Message: msg,
|
||||
Detail: payload,
|
||||
},
|
||||
}
|
||||
payload["note"] = fmt.Sprintf("%d item(s) failed during +sync", failed)
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return runtime.OutPartialFailure(payload, nil)
|
||||
}
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
@@ -555,7 +526,7 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[
|
||||
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
|
||||
if runtime.IO().In == nil {
|
||||
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath).WithParam("--on-conflict")
|
||||
}
|
||||
reader, ok := runtime.IO().In.(*bufio.Reader)
|
||||
if !ok {
|
||||
@@ -564,12 +535,12 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
|
||||
}
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read conflict choice for %q: %s", relPath, err).WithParam("--on-conflict")
|
||||
}
|
||||
answer := strings.TrimSpace(strings.ToLower(line))
|
||||
if answer == "" {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath).WithParam("--on-conflict")
|
||||
}
|
||||
return driveSyncOnConflictRemoteWins, nil
|
||||
}
|
||||
@@ -583,7 +554,7 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin
|
||||
case "r", "remote", "remote-wins":
|
||||
return driveSyncOnConflictRemoteWins, nil
|
||||
default:
|
||||
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line)).WithParam("--on-conflict")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,16 +606,16 @@ func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderC
|
||||
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
|
||||
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
if info.IsDir() {
|
||||
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "original path became a directory during rollback: %s", oldAbsPath)
|
||||
}
|
||||
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "remove partial restored path %q: %s", oldAbsPath, err).WithCause(err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "stat original path %q during rollback: %s", oldAbsPath, err).WithCause(err)
|
||||
}
|
||||
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
|
||||
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "restore renamed local file %q: %s", oldAbsPath, err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -1434,14 +1435,15 @@ func TestDriveSyncAskConflictEOFDuringExecuteReportsFailedItem(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected EOF failure during ask execution\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
// Collecting conflict decisions runs in the Phase-1 setup pass, before
|
||||
// any sync operation executes, so the EOF abort propagates the typed
|
||||
// *errs.ValidationError unchanged rather than a synthetic partial_failure.
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
if len(items) == 0 || !strings.Contains(items[0].Error, "stdin reached EOF") {
|
||||
t.Fatalf("expected failed ask item, got detail: %#v", exitErr.Detail.Detail)
|
||||
if !strings.Contains(validationErr.Error(), "stdin reached EOF") {
|
||||
t.Fatalf("expected EOF failure, got: %v", validationErr)
|
||||
}
|
||||
data, readErr := os.ReadFile("local/a.txt")
|
||||
if readErr != nil {
|
||||
@@ -1503,12 +1505,15 @@ func TestDriveSyncAskConflictEOFDuringPlanningPreventsAnyWrites(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected EOF failure during ask planning\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "partial_failure" || !strings.Contains(exitErr.Error(), "stdin reached EOF") {
|
||||
t.Fatalf("expected planning failure detail mentioning EOF, got: %#v", exitErr.Detail)
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "stdin reached EOF") {
|
||||
t.Fatalf("expected planning failure mentioning EOF, got: %v", validationErr)
|
||||
}
|
||||
if data, readErr := os.ReadFile("local/a.txt"); readErr != nil || string(data) != "local-a" {
|
||||
t.Fatalf("a.txt should remain untouched, readErr=%v content=%q", readErr, string(data))
|
||||
@@ -1706,14 +1711,10 @@ func TestDriveSyncReportsNewRemoteDownloadFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected download failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") {
|
||||
t.Fatalf("expected failed pull item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed pull item, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1758,14 +1759,10 @@ func TestDriveSyncReportsNewLocalEnsureFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected ensure failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "create parent failed") {
|
||||
t.Fatalf("expected failed push item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed push item, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1810,14 +1807,10 @@ func TestDriveSyncReportsNewLocalUploadFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected upload failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "upload failed") {
|
||||
t.Fatalf("expected failed upload item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed upload item, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1875,14 +1868,10 @@ func TestDriveSyncLocalWinsReportsUploadFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || items[0].Direction != "push" || !strings.Contains(items[0].Error, "overwrite failed") {
|
||||
t.Fatalf("expected failed overwrite item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed overwrite item, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1965,30 +1954,13 @@ func TestDriveSyncKeepBothReportsRenameFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected keep-both suffix exhaustion error\nstdout: %s", stdout.String())
|
||||
}
|
||||
// The error may be a plain ExitError (no Detail.Detail) or a
|
||||
// partial_failure with items. Either way it must mention the
|
||||
// suffix exhaustion.
|
||||
errMsg := err.Error()
|
||||
// The suffix exhaustion message may be in the top-level error or
|
||||
// inside a partial_failure detail item. Check both.
|
||||
foundSuffixError := strings.Contains(errMsg, "could not generate a unique rel_path")
|
||||
if !foundSuffixError {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
for _, item := range items {
|
||||
if strings.Contains(item.Error, "could not generate a unique rel_path") {
|
||||
foundSuffixError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSuffixError {
|
||||
t.Fatalf("expected suffix exhaustion error, got: %s; detail: %#v", errMsg, exitErr.Detail.Detail)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("expected suffix exhaustion error, got: %s", errMsg)
|
||||
}
|
||||
// The suffix-exhaustion failure is an item-level conflict failure, so
|
||||
// it surfaces as the partial-failure signal: a typed PartialFailureError
|
||||
// on the error channel and the ok:false items[] payload (carrying the
|
||||
// suffix message) on stdout via OutPartialFailure.
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
if !strings.Contains(stdout.String(), "could not generate a unique rel_path") {
|
||||
t.Fatalf("expected suffix exhaustion error in stdout items, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2341,14 +2313,10 @@ func TestDriveSyncRemoteWinsReportsModifiedPullFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected modified pull failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || items[0].Direction != "pull" || !strings.Contains(items[0].Error, "save failed") {
|
||||
t.Fatalf("expected failed modified pull item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed modified pull item, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2411,14 +2379,10 @@ func TestDriveSyncKeepBothReportsRollbackFailureAfterPullError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected keep-both rollback failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || !strings.Contains(items[0].Error, "rollback failed") {
|
||||
t.Fatalf("expected rollback failure in item error, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected rollback failure in item error, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2500,14 +2464,10 @@ func TestDriveSyncLocalWinsNestedFileReportsParentEnsureFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || !strings.Contains(items[0].Error, "create parent failed") {
|
||||
t.Fatalf("expected failed item with create_folder error, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed item with create_folder error, got detail: %#v", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2704,7 +2664,7 @@ func TestDriveSyncKeepBothReportsSuffixError(t *testing.T) {
|
||||
// TestDriveSyncKeepBothRollbackSucceedsOnPullFailure verifies the full
|
||||
// keep-both rollback path: when the pull download fails after the local
|
||||
// file has been renamed, the rollback restores the original file and
|
||||
// the error is reported as a partial_failure.
|
||||
// the failure is reported via the partial-failure signal.
|
||||
func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) {
|
||||
syncTestConfig := &core.CliConfig{
|
||||
AppID: "drive-sync-keep-both-rollback-pull-fail", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -2762,14 +2722,10 @@ func TestDriveSyncKeepBothRollbackSucceedsOnPullFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected keep-both pull failure with rollback\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 || !strings.Contains(items[0].Error, "save failed") {
|
||||
t.Fatalf("expected save failure in item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected save failure in item, got detail: %#v", stdout.String())
|
||||
}
|
||||
|
||||
// Rollback should have restored the original file.
|
||||
@@ -2978,14 +2934,10 @@ func TestDriveSyncLocalWinsUsesReturnedTokenOnUploadFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected local-wins upload failure\nstdout: %s", stdout.String())
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured ExitError, got: %v", err)
|
||||
}
|
||||
detailMap, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
items, _ := detailMap["items"].([]driveSyncItem)
|
||||
assertDriveSyncPartialFailure(t, err)
|
||||
items := driveSyncStdoutItems(t, stdout.Bytes())
|
||||
if len(items) == 0 {
|
||||
t.Fatalf("expected failed item, got detail: %#v", exitErr.Detail.Detail)
|
||||
t.Fatalf("expected failed item, got detail: %#v", stdout.String())
|
||||
}
|
||||
// The reported token should be the new one from the partial-success
|
||||
// response, not the stale existingToken ("tok_a").
|
||||
@@ -3095,3 +3047,39 @@ func TestDriveSyncRejectsLocalDirVsRemoteFileTypeConflict(t *testing.T) {
|
||||
t.Fatalf("error should mention local directory, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertDriveSyncPartialFailure asserts that err is the typed partial-failure
|
||||
// exit signal +sync returns on any item-level failure. The structured
|
||||
// {detection, diff, summary, items, note} payload rides on stdout as an
|
||||
// ok:false envelope via runtime.OutPartialFailure (in alignment with
|
||||
// +push/+pull), so this helper only checks the exit-code signal; callers read
|
||||
// the payload from stdout.
|
||||
func assertDriveSyncPartialFailure(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected partial-failure exit signal, got nil")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
}
|
||||
|
||||
// driveSyncStdoutItems extracts the items[] payload from the stdout envelope
|
||||
// written by runtime.Out. The per-item failure context that used to live in
|
||||
// the partial_failure ExitError detail now rides on stdout.
|
||||
func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Items []driveSyncItem `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
}
|
||||
return envelope.Data.Items
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -43,34 +43,34 @@ var DriveTaskResult = common.Shortcut{
|
||||
"wiki_delete_node": true,
|
||||
}
|
||||
if !validScenarios[scenario] {
|
||||
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
|
||||
}
|
||||
|
||||
// Validate required params based on scenario
|
||||
switch scenario {
|
||||
case "import", "export":
|
||||
if runtime.Str("ticket") == "" {
|
||||
return output.ErrValidation("--ticket is required for %s scenario", scenario)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ticket is required for %s scenario", scenario).WithParam("--ticket")
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
|
||||
}
|
||||
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
|
||||
if runtime.Str("task-id") == "" {
|
||||
return output.ErrValidation("--task-id is required for %s scenario", scenario)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
|
||||
}
|
||||
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
|
||||
}
|
||||
}
|
||||
|
||||
// For export scenario, file-token is required
|
||||
if scenario == "export" && runtime.Str("file-token") == "" {
|
||||
return output.ErrValidation("--file-token is required for export scenario")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required for export scenario").WithParam("--file-token")
|
||||
}
|
||||
if scenario == "export" {
|
||||
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,9 +261,10 @@ func requireDriveScopes(storedScopes string, required []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))
|
||||
}
|
||||
|
||||
func missingDriveScopes(storedScopes string, required []string) []string {
|
||||
@@ -408,10 +409,10 @@ func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[strin
|
||||
|
||||
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
|
||||
return wikiMoveTaskQueryStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "move"},
|
||||
@@ -426,7 +427,7 @@ func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiM
|
||||
|
||||
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
|
||||
if task == nil {
|
||||
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return wikiMoveTaskQueryStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
status := wikiMoveTaskQueryStatus{
|
||||
@@ -490,10 +491,10 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
|
||||
// rather than the per-node array used by wiki move.
|
||||
func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_space"},
|
||||
@@ -505,7 +506,7 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
|
||||
|
||||
task := common.GetMap(data, "task")
|
||||
if task == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
resolvedTaskID := common.GetString(task, "task_id")
|
||||
@@ -558,10 +559,10 @@ func queryWikiDeleteSpaceTask(runtime *common.RuntimeContext, taskID string) (ma
|
||||
// keep drive from depending on shortcuts/wiki.
|
||||
func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
|
||||
map[string]interface{}{"task_type": "delete_node"},
|
||||
@@ -573,7 +574,7 @@ func queryWikiDeleteNodeTask(runtime *common.RuntimeContext, taskID string) (map
|
||||
|
||||
task := common.GetMap(data, "task")
|
||||
if task == nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
|
||||
}
|
||||
|
||||
resolvedTaskID := common.GetString(task, "task_id")
|
||||
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -86,6 +88,16 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
var vErr *errs.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if vErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -428,6 +440,16 @@ func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
|
||||
t.Fatalf("expected missing wiki scope error, got %v", err)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope)
|
||||
}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "wiki:space:read" {
|
||||
t.Fatalf("MissingScopes = %v, want [wiki:space:read]", permErr.MissingScopes)
|
||||
}
|
||||
})
|
||||
t.Run(scenario+"/accepts wiki scope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -663,6 +685,19 @@ func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "missing task") {
|
||||
t.Fatalf("expected missing task error, got %v", err)
|
||||
}
|
||||
// A successful API call (code==0) that omits the `task` field is a
|
||||
// malformed RESPONSE, not a user error: classify as internal /
|
||||
// invalid_response (exit 5), not an API business error (exit 1).
|
||||
var iErr *errs.InternalError
|
||||
if !errors.As(err, &iErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
if iErr.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Subtype = %q, want %q", iErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want ExitInternal (%d)", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -151,7 +151,7 @@ var DriveUpload = common.Shortcut{
|
||||
|
||||
info, err := runtime.FileIO().Stat(spec.FilePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
return driveInputStatError(err)
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
@@ -194,13 +194,13 @@ var DriveUpload = common.Shortcut{
|
||||
|
||||
func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpec) error {
|
||||
if driveUploadFlagExplicitlyEmpty(runtime, "file-token") {
|
||||
return common.FlagErrorf("--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token cannot be empty; omit --file-token for a new upload or pass an existing file token to overwrite").WithParam("--file-token")
|
||||
}
|
||||
if driveUploadFlagExplicitlyEmpty(runtime, "folder-token") {
|
||||
return common.FlagErrorf("--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token cannot be empty; omit --folder-token to upload into Drive root folder or pass a folder token").WithParam("--folder-token")
|
||||
}
|
||||
if driveUploadFlagExplicitlyEmpty(runtime, "wiki-token") {
|
||||
return common.FlagErrorf("--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wiki-token cannot be empty; omit --wiki-token to upload into Drive root folder or pass a wiki node token").WithParam("--wiki-token")
|
||||
}
|
||||
|
||||
targets := 0
|
||||
@@ -211,21 +211,21 @@ func validateDriveUploadSpec(runtime *common.RuntimeContext, spec driveUploadSpe
|
||||
targets++
|
||||
}
|
||||
if targets > 1 {
|
||||
return common.FlagErrorf("--folder-token and --wiki-token are mutually exclusive")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token and --wiki-token are mutually exclusive")
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
}
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
if err := validate.ResourceName(spec.WikiToken, "--wiki-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--wiki-token")
|
||||
}
|
||||
}
|
||||
if spec.FileToken != "" {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -240,7 +240,7 @@ func driveUploadFlagExplicitlyEmpty(runtime *common.RuntimeContext, flagName str
|
||||
func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName string, target driveUploadTarget, fileSize int64, existingFileToken string) (driveUploadResult, error) {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return driveUploadResult{}, common.WrapInputStatError(err)
|
||||
return driveUploadResult{}, driveInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -265,23 +265,16 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
if errors.As(err, &exitErr) {
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
return driveUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken := common.GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
}
|
||||
return driveUploadResult{
|
||||
FileToken: fileToken,
|
||||
@@ -304,7 +297,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
if existingFileToken != "" {
|
||||
prepareBody["file_token"] = existingFileToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
@@ -316,7 +309,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
blockNum := int(blockNumF)
|
||||
|
||||
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
@@ -334,7 +327,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
|
||||
partFile, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return driveUploadResult{}, common.WrapInputStatError(err)
|
||||
return driveUploadResult{}, driveInputStatError(err)
|
||||
}
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
@@ -354,16 +347,11 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
if errors.As(err, &exitErr) {
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
return driveUploadResult{}, output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, err)
|
||||
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err)
|
||||
}
|
||||
|
||||
var partResult map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &partResult); err != nil {
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload part %d/%d: invalid response JSON: %v", seq+1, blockNum, err)
|
||||
}
|
||||
if larkCode := int(common.GetFloat(partResult, "code")); larkCode != 0 {
|
||||
msg, _ := partResult["msg"].(string)
|
||||
return driveUploadResult{}, output.ErrAPI(larkCode, fmt.Sprintf("upload part %d/%d failed: [%d] %s", seq+1, blockNum, larkCode, msg), partResult["error"])
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
@@ -374,14 +362,14 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
}
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
|
||||
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
|
||||
if err != nil {
|
||||
return driveUploadResult{}, err
|
||||
}
|
||||
|
||||
fileToken := common.GetString(finishResult, "file_token")
|
||||
if fileToken == "" {
|
||||
return driveUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
|
||||
return driveUploadResult{
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -34,10 +34,10 @@ type driveVersionHistorySpec struct {
|
||||
func validateDriveNumericValue(value, flagName, valueLabel string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return output.ErrValidation("%s cannot be empty", flagName)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if !driveVersionNumberRe.MatchString(value) {
|
||||
return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a numeric %s", flagName, valueLabel).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -52,10 +52,10 @@ func validateDriveCursorValue(value, flagName string) error {
|
||||
|
||||
func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if spec.Limit < 1 || spec.Limit > 200 {
|
||||
return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --limit %d: must be between 1 and 200", spec.Limit).WithParam("--limit")
|
||||
}
|
||||
if spec.Cursor != "" {
|
||||
if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil {
|
||||
@@ -180,7 +180,7 @@ var DriveVersionHistory = common.Shortcut{
|
||||
Cursor: strings.TrimSpace(runtime.Str("cursor")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)),
|
||||
driveVersionHistoryParams(spec),
|
||||
@@ -214,7 +214,7 @@ type driveVersionGetSpec struct {
|
||||
|
||||
func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
if err := validateDriveVersionValue(spec.Version, "--version"); err != nil {
|
||||
return err
|
||||
@@ -223,7 +223,7 @@ func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersi
|
||||
return nil
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(spec.Output); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -299,7 +299,7 @@ var DriveVersionGet = common.Shortcut{
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
return wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -315,10 +315,10 @@ var DriveVersionGet = common.Shortcut{
|
||||
outputPath, _ = common.AutoAppendDownloadExtension(outputPath, resp.Header, "")
|
||||
}
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", resolveErr)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite {
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
@@ -326,7 +326,7 @@ var DriveVersionGet = common.Shortcut{
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
return driveSaveError(err)
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
@@ -354,7 +354,7 @@ type driveVersionMutationSpec struct {
|
||||
|
||||
func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
}
|
||||
return validateDriveVersionValue(spec.Version, "--version")
|
||||
}
|
||||
@@ -392,7 +392,7 @@ var DriveVersionRevert = common.Shortcut{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
Version: strings.TrimSpace(runtime.Str("version")),
|
||||
}
|
||||
if _, err := runtime.CallAPI(
|
||||
if _, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)),
|
||||
nil,
|
||||
@@ -439,7 +439,7 @@ var DriveVersionDelete = common.Shortcut{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
Version: strings.TrimSpace(runtime.Str("version")),
|
||||
}
|
||||
if _, err := runtime.CallAPI(
|
||||
if _, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)),
|
||||
nil,
|
||||
|
||||
@@ -5,14 +5,17 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -53,6 +56,16 @@ func TestValidateDriveVersionHistorySpec(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
var vErr *errs.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if vErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -255,6 +268,13 @@ func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "output file already exists") {
|
||||
t.Fatalf("expected output exists error, got %v", err)
|
||||
}
|
||||
var vErr *errs.ValidationError
|
||||
if !errors.As(err, &vErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if vErr.Subtype != errs.SubtypeInvalidArgument || vErr.Param != "--output" {
|
||||
t.Fatalf("typed shape = subtype %q param %q, want invalid_argument/--output", vErr.Subtype, vErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) {
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -85,7 +86,7 @@ func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
|
||||
result, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -176,24 +177,27 @@ func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemote
|
||||
return duplicates
|
||||
}
|
||||
|
||||
// Deprecated: duplicateRemotePathError produces a legacy *output.ExitError
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — duplicate-path signals should move to a typed
|
||||
// *errs.ValidationError (with duplicates metadata as a typed extension
|
||||
// field) when the drive shortcut migrates to typed errors. This helper is
|
||||
// retained only while existing call sites are migrated; it will be removed
|
||||
// once they have moved to the typed surface.
|
||||
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "duplicate_remote_path",
|
||||
Message: "multiple Drive entries map to the same rel_path",
|
||||
Detail: map[string]interface{}{
|
||||
"duplicates_remote": duplicates,
|
||||
},
|
||||
},
|
||||
// duplicateRemotePathError reports that multiple Drive entries resolve to the
|
||||
// same rel_path. Each colliding rel_path becomes one InvalidParam whose Name is
|
||||
// the rel_path and whose Reason enumerates the colliding entries (type +
|
||||
// file_token), so an AI agent reading the typed envelope can identify exactly
|
||||
// which Drive objects collide without re-listing the folder.
|
||||
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) error {
|
||||
params := make([]errs.InvalidParam, 0, len(duplicates))
|
||||
for _, d := range duplicates {
|
||||
descriptions := make([]string, 0, len(d.Entries))
|
||||
for _, entry := range d.Entries {
|
||||
descriptions = append(descriptions, fmt.Sprintf("%s %s", entry.Type, entry.FileToken))
|
||||
}
|
||||
params = append(params, errs.InvalidParam{
|
||||
Name: d.RelPath,
|
||||
Reason: fmt.Sprintf("%d Drive entries collide here: %s", len(d.Entries), strings.Join(descriptions, ", ")),
|
||||
})
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"%d rel_path(s) map to multiple Drive entries", len(duplicates)).
|
||||
WithHint("resolve the duplicate remote files first: re-run +pull with --on-duplicate-remote=rename (downloads each with a hashed suffix), or use --on-duplicate-remote=newest|oldest (supported by +pull/+sync/+push) to pick one, or delete the extra remote files; a plain retry will not help").
|
||||
WithParams(params...)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -300,7 +304,7 @@ func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (
|
||||
|
||||
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
|
||||
if len(files) == 0 {
|
||||
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
|
||||
return driveRemoteEntry{}, errs.NewInternalError(errs.SubtypeUnknown, "no Drive entries available for strategy %q", strategy)
|
||||
}
|
||||
candidates := append([]driveRemoteEntry(nil), files...)
|
||||
sortRemoteFiles(candidates, strategy)
|
||||
@@ -385,7 +389,7 @@ func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[st
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
|
||||
}
|
||||
|
||||
// joinRelDrive joins a rel_path base with an entry name using "/".
|
||||
|
||||
@@ -50,11 +50,12 @@ var cardChartTypeNames = map[string]string{
|
||||
type interactiveConverter struct{}
|
||||
|
||||
func (interactiveConverter) Convert(ctx *ConvertContext) string {
|
||||
return convertCard(ctx.RawContent)
|
||||
return convertCard(ctx.RawContent, ctx.Mentions)
|
||||
}
|
||||
|
||||
// convertCard converts a raw interactive/card message content JSON to human-readable string.
|
||||
func convertCard(raw string) string {
|
||||
// mentions is the raw mentions array from the API response; pass nil when not available.
|
||||
func convertCard(raw string, mentions []interface{}) string {
|
||||
var parsed cardObj
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return "[interactive card]"
|
||||
@@ -63,11 +64,19 @@ func convertCard(raw string) string {
|
||||
// raw_card_content format: outer JSON has "json_card" string field
|
||||
if jsonCard, ok := parsed["json_card"].(string); ok {
|
||||
c := &cardConverter{mode: cardModeConcise}
|
||||
if att, ok := parsed["json_attachment"].(string); ok && att != "" {
|
||||
var attObj cardObj
|
||||
if json.Unmarshal([]byte(att), &attObj) == nil {
|
||||
c.attachment = attObj
|
||||
switch att := parsed["json_attachment"].(type) {
|
||||
case string:
|
||||
if att != "" {
|
||||
var attObj cardObj
|
||||
if json.Unmarshal([]byte(att), &attObj) == nil {
|
||||
c.attachment = attObj
|
||||
}
|
||||
}
|
||||
case cardObj:
|
||||
c.attachment = att
|
||||
}
|
||||
if len(mentions) > 0 {
|
||||
c.mentionsByKey = buildMentionsByKey(mentions)
|
||||
}
|
||||
schema := 0
|
||||
if s, ok := parsed["card_schema"].(float64); ok {
|
||||
@@ -84,6 +93,22 @@ func convertCard(raw string) string {
|
||||
return convertLegacyCard(parsed)
|
||||
}
|
||||
|
||||
// buildMentionsByKey indexes the mentions array by key for O(1) lookup in convertAt.
|
||||
func buildMentionsByKey(mentions []interface{}) map[string]map[string]interface{} {
|
||||
m := make(map[string]map[string]interface{}, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, _ := item["key"].(string)
|
||||
if key != "" {
|
||||
m[key] = item
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ── Legacy converter ──────────────────────────────────────────────────────────
|
||||
|
||||
func convertLegacyCard(parsed cardObj) string {
|
||||
@@ -158,8 +183,9 @@ func legacyExtractTexts(elements []interface{}, out *[]string) {
|
||||
// ── CardConverter ─────────────────────────────────────────────────────────────
|
||||
|
||||
type cardConverter struct {
|
||||
mode cardMode
|
||||
attachment cardObj
|
||||
mode cardMode
|
||||
attachment cardObj
|
||||
mentionsByKey map[string]map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
|
||||
@@ -1403,26 +1429,52 @@ func (c *cardConverter) convertAt(prop cardObj) string {
|
||||
}
|
||||
userName := ""
|
||||
actualUserID := ""
|
||||
fromMentions := false
|
||||
if c.attachment != nil {
|
||||
if atUsers, ok := c.attachment["at_users"].(cardObj); ok {
|
||||
if userInfo, ok := atUsers[userID].(cardObj); ok {
|
||||
userName, _ = userInfo["content"].(string)
|
||||
actualUserID, _ = userInfo["user_id"].(string)
|
||||
// When the backend populates mention_key (raw_card_content path), use
|
||||
// mentions[] for the canonical name and the reading-app open_id, which is
|
||||
// more accurate than the origKey-stored user_id in at_users.
|
||||
if mentionKey, _ := userInfo["mention_key"].(string); mentionKey != "" {
|
||||
if mention, ok := c.mentionsByKey[mentionKey]; ok {
|
||||
if name, _ := mention["name"].(string); name != "" {
|
||||
userName = name
|
||||
}
|
||||
if id := extractMentionOpenId(mention["id"]); id != "" {
|
||||
actualUserID = id
|
||||
fromMentions = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if userName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
if actualUserID != "" {
|
||||
return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID)
|
||||
label := "user_id"
|
||||
if fromMentions {
|
||||
label = "open_id"
|
||||
}
|
||||
return fmt.Sprintf("@%s(%s:%s)", userName, label, actualUserID)
|
||||
}
|
||||
return fmt.Sprintf("@%s(open_id:%s)", userName, userID)
|
||||
}
|
||||
return "@" + userName
|
||||
if fromMentions && actualUserID != "" {
|
||||
return fmt.Sprintf("@%s(%s)", userName, actualUserID)
|
||||
}
|
||||
return fmt.Sprintf("@%s(%s)", userName, userID)
|
||||
}
|
||||
if c.mode == cardModeDetailed {
|
||||
if actualUserID != "" {
|
||||
return fmt.Sprintf("@user(user_id:%s)", actualUserID)
|
||||
label := "user_id"
|
||||
if fromMentions {
|
||||
label = "open_id"
|
||||
}
|
||||
return fmt.Sprintf("@user(%s:%s)", label, actualUserID)
|
||||
}
|
||||
return fmt.Sprintf("@user(open_id:%s)", userID)
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ func newTestCardConverter(mode cardMode) *cardConverter {
|
||||
|
||||
func TestConvertCard(t *testing.T) {
|
||||
rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}`
|
||||
got := convertCard(rawCard)
|
||||
got := convertCard(rawCard, nil)
|
||||
want := "<card title=\"Card Title\">\nhello\n[Open](https://example.com)\n</card>"
|
||||
if got != want {
|
||||
t.Fatalf("convertCard(json_card) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}`
|
||||
gotLegacy := convertCard(legacy)
|
||||
gotLegacy := convertCard(legacy, nil)
|
||||
wantLegacy := "**Legacy Card**\nlegacy body"
|
||||
if gotLegacy != wantLegacy {
|
||||
t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy)
|
||||
@@ -243,6 +243,75 @@ func TestCardConverterMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAtWithMentions(t *testing.T) {
|
||||
mentions := []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_6b64bef911a5a3ea763df8ffd9258f59",
|
||||
"name": "燕忠毅",
|
||||
},
|
||||
}
|
||||
attachment := cardObj{
|
||||
"at_users": cardObj{
|
||||
"cde8a6c8": cardObj{
|
||||
"user_id": "754700000001",
|
||||
"content": "燕忠毅",
|
||||
"mention_key": "@_user_1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Concise mode: should show @Name(open_id) when mention resolves.
|
||||
concise := &cardConverter{
|
||||
mode: cardModeConcise,
|
||||
attachment: attachment,
|
||||
mentionsByKey: buildMentionsByKey(mentions),
|
||||
}
|
||||
if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" {
|
||||
t.Fatalf("convertAt(concise with mentions) = %q", got)
|
||||
}
|
||||
|
||||
// Detailed mode: label should be open_id when resolved from mentions.
|
||||
detailed := &cardConverter{
|
||||
mode: cardModeDetailed,
|
||||
attachment: attachment,
|
||||
mentionsByKey: buildMentionsByKey(mentions),
|
||||
}
|
||||
if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" {
|
||||
t.Fatalf("convertAt(detailed with mentions) = %q", got)
|
||||
}
|
||||
|
||||
// No mention_key: falls back to at_users.user_id with user_id label (existing behavior).
|
||||
noMentionKey := &cardConverter{
|
||||
mode: cardModeDetailed,
|
||||
attachment: cardObj{
|
||||
"at_users": cardObj{
|
||||
"ou_at": cardObj{"content": "Bob", "user_id": "u_bob"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := noMentionKey.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" {
|
||||
t.Fatalf("convertAt(fallback no mention_key) = %q", got)
|
||||
}
|
||||
|
||||
// mention_key present but mentionsByKey nil: still falls back gracefully.
|
||||
nilMentions := &cardConverter{
|
||||
mode: cardModeDetailed,
|
||||
attachment: cardObj{
|
||||
"at_users": cardObj{
|
||||
"cde8a6c8": cardObj{
|
||||
"user_id": "754700000001",
|
||||
"content": "燕忠毅",
|
||||
"mention_key": "@_user_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" {
|
||||
t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardConverterExtractTextHelpers(t *testing.T) {
|
||||
c := newTestCardConverter(cardModeDetailed)
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ type ContentConverter interface {
|
||||
type ConvertContext struct {
|
||||
RawContent string
|
||||
MentionMap map[string]string
|
||||
// Mentions is the raw mentions array from the API response.
|
||||
// Used by interactive card converter to resolve @user references via mention_key.
|
||||
Mentions []interface{}
|
||||
// MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API.
|
||||
// For other message types these can be zero values.
|
||||
MessageID string
|
||||
@@ -93,6 +96,7 @@ func FormatEventMessage(msgType, rawContent, messageID string, mentions []interf
|
||||
content := ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
MessageID: messageID,
|
||||
})
|
||||
|
||||
@@ -153,6 +157,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
|
||||
content = ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
MessageID: messageId,
|
||||
Runtime: runtime,
|
||||
SenderNames: nameCache,
|
||||
|
||||
@@ -320,6 +320,7 @@ func FormatMergeForwardSubTree(parentID string, childrenMap map[string][]map[str
|
||||
content = ConvertBodyContent(msgType, &ConvertContext{
|
||||
RawContent: rawContent,
|
||||
MentionMap: BuildMentionKeyMap(mentions),
|
||||
Mentions: mentions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -325,3 +325,29 @@ func TestMergeForwardConverterWithRuntime(t *testing.T) {
|
||||
t.Fatalf("mergeForwardConverter.Convert(runtime) = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMergeForwardSubTreeInteractiveCardUsesMentions(t *testing.T) {
|
||||
cardContent := `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"at\",\"property\":{\"userID\":\"cde8a6c8\"}}]}}","json_attachment":"{\"at_users\":{\"cde8a6c8\":{\"user_id\":\"754700000001\",\"content\":\"Alice\",\"mention_key\":\"@_user_1\"}}}"}`
|
||||
items := []map[string]interface{}{
|
||||
{
|
||||
"message_id": "om_card",
|
||||
"msg_type": "interactive",
|
||||
"create_time": "1710500000000",
|
||||
"sender": map[string]interface{}{"name": "Sender"},
|
||||
"body": map[string]interface{}{"content": cardContent},
|
||||
"mentions": []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_real_open_id",
|
||||
"name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
children := BuildMergeForwardChildrenMap(items, "om_root")
|
||||
got := FormatMergeForwardSubTree("om_root", children)
|
||||
if !strings.Contains(got, "@Alice(ou_real_open_id)") {
|
||||
t.Fatalf("FormatMergeForwardSubTree(interactive card) = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVG,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
}
|
||||
|
||||
@@ -121,35 +121,19 @@ var SlidesCreate = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
content := buildPresentationXML(title)
|
||||
slidesStr := runtime.Str("slides")
|
||||
|
||||
// Step 1: Create presentation
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
)
|
||||
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"title": title,
|
||||
}
|
||||
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
|
||||
result["revision_id"] = int(revisionID)
|
||||
if revisionID > 0 {
|
||||
result["revision_id"] = revisionID
|
||||
}
|
||||
|
||||
// Step 2: Add slides if provided
|
||||
@@ -198,6 +182,9 @@ var SlidesCreate = common.Shortcut{
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
}
|
||||
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
|
||||
result["revision_id"] = int(latest)
|
||||
}
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
@@ -205,34 +192,7 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch presentation URL via drive meta (best-effort)
|
||||
if metaData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": presentationID,
|
||||
"doc_type": "slides",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
},
|
||||
); err == nil {
|
||||
metas := common.GetSlice(metaData, "metas")
|
||||
if len(metas) > 0 {
|
||||
if meta, ok := metas[0].(map[string]interface{}); ok {
|
||||
if url := common.GetString(meta, "url"); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
fillPresentationResult(runtime, presentationID, result)
|
||||
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
@@ -259,6 +219,41 @@ func buildPresentationXML(title string) string {
|
||||
)
|
||||
}
|
||||
|
||||
func createEmptyPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": buildPresentationXML(title),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return "", 0, output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
}
|
||||
revisionID := 0
|
||||
if rev := common.GetFloat(data, "revision_id"); rev > 0 {
|
||||
revisionID = int(rev)
|
||||
}
|
||||
return presentationID, revisionID, nil
|
||||
}
|
||||
|
||||
func fillPresentationResult(runtime *common.RuntimeContext, presentationID string, result map[string]interface{}) {
|
||||
if url, err := common.FetchDriveMetaURL(runtime, presentationID, "slides"); err == nil && url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
// uploadSlidesPlaceholders uploads each unique placeholder path against the
|
||||
// presentation and returns the path→file_token map. The second return value is
|
||||
// the number of files successfully uploaded before any error, so callers can
|
||||
|
||||
189
shortcuts/slides/slides_create_svg.go
Normal file
189
shortcuts/slides/slides_create_svg.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVG creates a new Lark Slides presentation from one or more
|
||||
// SVGlide SVG files by adding each page through the existing XML slide route.
|
||||
var SlidesCreateSVG = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svg",
|
||||
Description: "Create a Lark Slides presentation from SVG",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{
|
||||
"slides:presentation:create",
|
||||
"slides:presentation:write_only",
|
||||
"docs:document.media:upload",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "presentation title"},
|
||||
{
|
||||
Name: "file",
|
||||
Type: "string_array",
|
||||
Required: true,
|
||||
Desc: "SVG file path; repeat for multiple pages",
|
||||
},
|
||||
{Name: "assets", Desc: "optional assets.json path mapping SVG @path placeholders to uploaded file tokens"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSVGAssetsPath(runtime, runtime.Str("assets"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
filePaths := runtime.StrArray("file")
|
||||
svgs, err := readSVGFiles(runtime, filePaths)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
classified, err := classifySVGlideSVGPages(filePaths, svgs)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
rewriteResult, uploadPaths, err := dryRunRewriteClassifiedSVGPages(classified, assets)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
pages := rewriteResult.Pages
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
total := 1 + len(uploadPaths) + len(pages)
|
||||
descSuffix := ""
|
||||
if len(uploadPaths) > 0 {
|
||||
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(uploadPaths))
|
||||
}
|
||||
dry.Desc(fmt.Sprintf("Create presentation from %d SVG page(s)%s", len(pages), descSuffix)).
|
||||
POST("/open-apis/slides_ai/v1/xml_presentations").
|
||||
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
|
||||
Body(map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
|
||||
})
|
||||
|
||||
for i, path := range uploadPaths {
|
||||
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
|
||||
}
|
||||
|
||||
slideStepStart := 2 + len(uploadPaths)
|
||||
for i, page := range pages {
|
||||
content, injectErr := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
|
||||
if injectErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", injectErr.Error())
|
||||
}
|
||||
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
|
||||
Desc(fmt.Sprintf("[%d/%d] Add SVG page %d", slideStepStart+i, total, i+1)).
|
||||
Params(map[string]interface{}{"revision_id": -1}).
|
||||
Body(buildCreateSVGBody(content))
|
||||
}
|
||||
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
|
||||
}
|
||||
return dry.Set("title", title)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
filePaths := runtime.StrArray("file")
|
||||
svgs, err := readSVGFiles(runtime, filePaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
classified, err := classifySVGlideSVGPages(filePaths, svgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasFallbackPages(classified) {
|
||||
if err := svgFallbackRasterizer.CheckAvailable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
renderedFallbacks, err := renderSVGFallbackPages(ctx, classified, svgFallbackRasterizer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupRenderedSVGFallbacks(renderedFallbacks)
|
||||
|
||||
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"title": title,
|
||||
}
|
||||
if revisionID > 0 {
|
||||
result["revision_id"] = revisionID
|
||||
}
|
||||
|
||||
rewriteResult, err := rewriteClassifiedSVGPages(runtime, presentationID, classified, assets, renderedFallbacks)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
|
||||
err, presentationID, rewriteResult.ImagesUploaded)
|
||||
}
|
||||
if rewriteResult.ImagesUploaded > 0 {
|
||||
result["images_uploaded"] = rewriteResult.ImagesUploaded
|
||||
}
|
||||
if rewriteResult.FallbackPages > 0 {
|
||||
result["fallback_pages"] = rewriteResult.FallbackPages
|
||||
}
|
||||
pages := rewriteResult.Pages
|
||||
|
||||
slideURL := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
var slideIDs []string
|
||||
for i, page := range pages {
|
||||
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
|
||||
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
|
||||
}
|
||||
slideData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": -1},
|
||||
buildCreateSVGBody(content),
|
||||
)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
|
||||
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
|
||||
}
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
}
|
||||
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
|
||||
result["revision_id"] = int(latest)
|
||||
}
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
result["slides_added"] = len(slideIDs)
|
||||
fillPresentationResult(runtime, presentationID, result)
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
649
shortcuts/slides/slides_create_svg_test.go
Normal file
649
shortcuts/slides/slides_create_svg_test.go
Normal file
@@ -0,0 +1,649 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const testSVGlidePage1 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="320" height="180"/></svg>`
|
||||
const testSVGlidePage2 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><p xmlns="http://www.w3.org/1999/xhtml">second</p></foreignObject></svg>`
|
||||
|
||||
func TestSlidesCreateSVGMissingFileFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--title", "missing file",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing --file error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file") {
|
||||
t.Fatalf("err = %v, want mention of file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGFileMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "missing.svg",
|
||||
"--title", "missing svg",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing SVG")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing.svg") {
|
||||
t.Fatalf("err = %v, want mention of missing.svg", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGEmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("empty.svg", nil, 0o644); err != nil {
|
||||
t.Fatalf("write empty.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "empty.svg",
|
||||
"--title", "empty svg",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty SVG")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty.svg") || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("err = %v, want empty.svg empty-file message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
|
||||
t.Fatalf("write page1.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
|
||||
t.Fatalf("write page2.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_svg",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
slideStub1 := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
|
||||
}
|
||||
slideStub2 := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}},
|
||||
}
|
||||
reg.Register(slideStub1)
|
||||
reg.Register(slideStub2)
|
||||
registerBatchQueryStub(reg, "pres_svg", "https://x.feishu.cn/slides/pres_svg")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", "SVG Deck",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_svg" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_svg", data["xml_presentation_id"])
|
||||
}
|
||||
if data["slides_added"] != float64(2) {
|
||||
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
|
||||
}
|
||||
if data["revision_id"] != float64(3) {
|
||||
t.Fatalf("revision_id = %v, want latest revision 3", data["revision_id"])
|
||||
}
|
||||
slideIDs, ok := data["slide_ids"].([]interface{})
|
||||
if !ok || len(slideIDs) != 2 || slideIDs[0] != "slide_1" || slideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
|
||||
}
|
||||
|
||||
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
|
||||
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
|
||||
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
|
||||
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
|
||||
t.Fatalf("write page1.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
|
||||
t.Fatalf("write page2.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_svg_partial",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_ok", "revision_id": 2}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": "invalid svg",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", "partial svg",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected slide create failure")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
for _, want := range []string{"pres_svg_partial", "page 2/2", "1 slide(s) added", "slide_ok"} {
|
||||
if !strings.Contains(errMsg, want) {
|
||||
t.Fatalf("err = %v, want mention of %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGFailureExtractsSVGlideMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_marker", "revision_id": 1}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_marker/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": `SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`,
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--title", "marker",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected marker failure")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject", "Use supported elements"} {
|
||||
if !strings.Contains(errMsg, want) {
|
||||
t.Fatalf("err = %v, want marker field %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGAssetsReplaceImageAndInjectMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></svg>`
|
||||
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
|
||||
t.Fatalf("write assets.json: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_asset", "revision_id": 1}},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_asset/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_asset", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_asset", "https://x.feishu.cn/slides/pres_asset")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--assets", "assets.json",
|
||||
"--title", "assets",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
if strings.Contains(content, "@./hero.png") || strings.Contains(content, "xlink:href") {
|
||||
t.Fatalf("content should canonicalize asset placeholder: %s", content)
|
||||
}
|
||||
for _, want := range []string{`href="boxcn_asset"`, `<metadata data-svglide-assets="true">`, `<img src="boxcn_asset" />`} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
|
||||
t.Fatalf("--assets token mapping should not upload local images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGNestedImageAssetsReplaceAndInjectMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><g transform="translate(10 20)"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></g></svg>`
|
||||
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
|
||||
t.Fatalf("write assets.json: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_nested_asset", "revision_id": 1}},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_nested_asset/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_nested_asset", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_nested_asset", "https://x.feishu.cn/slides/pres_nested_asset")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--assets", "assets.json",
|
||||
"--title", "nested assets",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{
|
||||
`href="boxcn_asset"`,
|
||||
`<metadata data-svglide-assets="true">`,
|
||||
`<img src="boxcn_asset" />`,
|
||||
`<g transform="translate(10 20)">`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{`xlink:href`, `@./hero.png`} {
|
||||
if strings.Contains(content, notWant) {
|
||||
t.Fatalf("content should not contain %s: %s", notWant, content)
|
||||
}
|
||||
}
|
||||
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
|
||||
t.Fatalf("--assets token mapping should not upload local images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" href="@hero.png" x="0" y="0" width="320" height="180"/></svg>`
|
||||
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("hero.png", []byte("png"), 0o644); err != nil {
|
||||
t.Fatalf("write hero.png: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_upload", "revision_id": 1}},
|
||||
})
|
||||
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": "boxcn_uploaded"}},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_upload/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_upload", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_upload", "https://x.feishu.cn/slides/pres_upload")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--title", "upload",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["images_uploaded"] != float64(1) {
|
||||
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{`href="boxcn_uploaded"`, `<img src="boxcn_uploaded" />`} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGFallbackRendersUploadsAndAddsImageOnlySVG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">render me</text></svg>`
|
||||
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write fallback.svg: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeSVGFallbackRasterizer{pngPath: "fallback.png", pngBytes: []byte("png-bytes")}
|
||||
restore := setTestSVGFallbackRasterizer(fake)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_fallback", "revision_id": 1}},
|
||||
})
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_fallback"}},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_fallback/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_fallback", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_fallback", "https://x.feishu.cn/slides/pres_fallback")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "fallback.svg",
|
||||
"--title", "fallback",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "fallback.svg" {
|
||||
t.Fatalf("rasterizer calls = %v, want [fallback.svg]", fake.calls)
|
||||
}
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["fallback_pages"] != float64(1) {
|
||||
t.Fatalf("fallback_pages = %v, want 1", data["fallback_pages"])
|
||||
}
|
||||
if data["images_uploaded"] != float64(1) {
|
||||
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{
|
||||
`slide:contract-version="svglide-authoring-contract/v1"`,
|
||||
`<image slide:role="image" href="boxcn_fallback" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
|
||||
`<metadata data-svglide-assets="true"><img src="boxcn_fallback" /></metadata>`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("fallback slide content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "<text") {
|
||||
t.Fatalf("fallback slide content should not contain original text node: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGRejectsUnsafeBeforePresentationCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`
|
||||
if err := os.WriteFile("unsafe.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write unsafe.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "unsafe.svg",
|
||||
"--title", "unsafe",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected preflight reject")
|
||||
}
|
||||
for _, want := range []string{"disallowed_script", "unsafe.svg"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGRendererUnavailableBeforePresentationCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">needs fallback</text></svg>`
|
||||
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write fallback.svg: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeSVGFallbackRasterizer{
|
||||
availableErr: newSVGlideDiagnosticsError("renderer unavailable", []SVGlideDiagnostic{{
|
||||
Code: svgDiagRendererUnavailable,
|
||||
Severity: svgDiagSeverityError,
|
||||
Path: "fallback.svg",
|
||||
Message: "renderer missing",
|
||||
}}),
|
||||
}
|
||||
restore := setTestSVGFallbackRasterizer(fake)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "fallback.svg",
|
||||
"--title", "renderer unavailable",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected renderer unavailable error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), svgDiagRendererUnavailable) {
|
||||
t.Fatalf("err = %v, want renderer_unavailable", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("rasterizer should not render when availability check fails, calls=%v", fake.calls)
|
||||
}
|
||||
if fake.checkCalls != 1 {
|
||||
t.Fatalf("renderer availability checks = %d, want 1", fake.checkCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGRasterFailureBeforePresentationCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">needs fallback</text></svg>`
|
||||
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write fallback.svg: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeSVGFallbackRasterizer{
|
||||
renderErr: newSVGlideDiagnosticsError("render failed", []SVGlideDiagnostic{{
|
||||
Code: svgDiagRendererFailed,
|
||||
Severity: svgDiagSeverityError,
|
||||
Path: "fallback.svg",
|
||||
Message: "render failed",
|
||||
}}),
|
||||
}
|
||||
restore := setTestSVGFallbackRasterizer(fake)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "fallback.svg",
|
||||
"--title", "render failure",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected raster failure error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), svgDiagRendererFailed) {
|
||||
t.Fatalf("err = %v, want renderer_failed", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("rasterizer calls = %v, want one render attempt", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSVGFallbackRasterizer struct {
|
||||
availableErr error
|
||||
renderErr error
|
||||
pngPath string
|
||||
pngBytes []byte
|
||||
checkCalls int
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeSVGFallbackRasterizer) CheckAvailable(context.Context) error {
|
||||
f.checkCalls++
|
||||
return f.availableErr
|
||||
}
|
||||
|
||||
func (f *fakeSVGFallbackRasterizer) Rasterize(_ context.Context, svgPath string) (string, int64, error) {
|
||||
f.calls = append(f.calls, svgPath)
|
||||
if f.renderErr != nil {
|
||||
return "", 0, f.renderErr
|
||||
}
|
||||
if f.pngPath == "" {
|
||||
f.pngPath = "fallback.png"
|
||||
}
|
||||
if len(f.pngBytes) == 0 {
|
||||
f.pngBytes = []byte("png")
|
||||
}
|
||||
if err := os.WriteFile(f.pngPath, f.pngBytes, 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return f.pngPath, int64(len(f.pngBytes)), nil
|
||||
}
|
||||
|
||||
func setTestSVGFallbackRasterizer(r svgRasterizer) func() {
|
||||
old := svgFallbackRasterizer
|
||||
svgFallbackRasterizer = r
|
||||
return func() {
|
||||
svgFallbackRasterizer = old
|
||||
}
|
||||
}
|
||||
|
||||
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "slides"}
|
||||
SlidesCreateSVG.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func assertSlideCreateBodyContains(t *testing.T, stub *httpmock.Stub, want string) {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
slide, _ := body["slide"].(map[string]interface{})
|
||||
content, _ := slide["content"].(string)
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("slide content = %s\nwant to contain %s", content, want)
|
||||
}
|
||||
}
|
||||
1473
shortcuts/slides/svg_helpers.go
Normal file
1473
shortcuts/slides/svg_helpers.go
Normal file
File diff suppressed because it is too large
Load Diff
513
shortcuts/slides/svg_helpers_test.go
Normal file
513
shortcuts/slides/svg_helpers_test.go
Normal file
@@ -0,0 +1,513 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svgs := []string{
|
||||
`<svg><image slide:role="image" href="@./hero.png"/><a href="@./link.png"/></svg>`,
|
||||
`<svg><image xlink:href='@./hero.png'/><image href = "@./other.png"/></svg>`,
|
||||
}
|
||||
got := extractSVGImagePlaceholderPaths(svgs, map[string]string{"@./other.png": "boxcn_other"})
|
||||
want := []string{"./hero.png"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSVGImagePlaceholdersWithTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := `<svg><image slide:role="image" href="@./hero.png"/><image xlink:href='@./logo.png'/><image data-href="@./ignored.png"/><a href="@./link.png">link</a><image href="https://example.com/noop.png"/></svg>`
|
||||
got, tokens := rewriteSVGImagePlaceholdersWithTokens(in, map[string]string{
|
||||
"./hero.png": "boxcn_hero",
|
||||
"./logo.png": "boxcn_logo",
|
||||
})
|
||||
for _, want := range []string{`href="boxcn_hero"`, `href="boxcn_logo"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("rewritten SVG missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "xlink:href") {
|
||||
t.Fatalf("rewritten SVG must not retain xlink:href: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `<a href="@./link.png">`) {
|
||||
t.Fatalf("non-image href should be untouched: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-href="@./ignored.png"`) {
|
||||
t.Fatalf("non-href image attribute should be untouched: %s", got)
|
||||
}
|
||||
wantTokens := []string{"boxcn_hero", "boxcn_logo"}
|
||||
if !reflect.DeepEqual(tokens, wantTokens) {
|
||||
t.Fatalf("tokens = %v, want %v", tokens, wantTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSVGTransportAssetMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect/></svg>`
|
||||
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b", "boxcn_a"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
rootIdx := strings.Index(got, "<svg")
|
||||
metaIdx := strings.Index(got, `<metadata data-svglide-assets="true">`)
|
||||
if rootIdx < 0 || metaIdx < rootIdx {
|
||||
t.Fatalf("metadata should be injected inside root <svg>, got: %s", got)
|
||||
}
|
||||
if strings.Count(got, `src="boxcn_a"`) != 1 {
|
||||
t.Fatalf("boxcn_a should be deduped, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `src="boxcn_b"`) {
|
||||
t.Fatalf("boxcn_b missing, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-assets="true"><img src="boxcn_a" /></metadata><image href="boxcn_a"/></svg>`
|
||||
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Count(got, `<metadata data-svglide-assets="true">`) != 1 {
|
||||
t.Fatalf("should keep a single transport metadata block, got: %s", got)
|
||||
}
|
||||
if strings.Count(got, `src="boxcn_a"`) != 1 {
|
||||
t.Fatalf("boxcn_a should remain deduped, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `src="boxcn_b"`) {
|
||||
t.Fatalf("boxcn_b should be appended, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySVGlideSVGPageRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
wantMode svgClassifyMode
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "native supported shape",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantMode: svgClassifyNative,
|
||||
},
|
||||
{
|
||||
name: "native supported server line role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><line slide:role="line" x1="0" y1="0" x2="100" y2="60" stroke="#112233"/></svg>`,
|
||||
wantMode: svgClassifyNative,
|
||||
},
|
||||
{
|
||||
name: "native supported server text role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
|
||||
wantMode: svgClassifyNative,
|
||||
},
|
||||
{
|
||||
name: "marked svg text still falls back",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text slide:role="text" x="20" y="40">render me</text></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "wrong contract native rejects",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagContractVersion,
|
||||
},
|
||||
{
|
||||
name: "wrong contract server text role rejects",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagContractVersion,
|
||||
},
|
||||
{
|
||||
name: "unsupported but renderable text falls back",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "wrong contract fallback-only svg still falls back",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "table defaults to fallback",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject x="20" y="40" width="400" height="240"><table xmlns="http://www.w3.org/1999/xhtml"><tr><td>a</td></tr></table></foreignObject></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "script rejects before create",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagDisallowedScript,
|
||||
},
|
||||
{
|
||||
name: "external href rejects before create",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image href="https://example.com/a.png" x="0" y="0" width="10" height="10"/></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagExternalReference,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := classifySVGlideSVGPage(tt.svg, "page.svg", 0)
|
||||
if got.Mode != tt.wantMode {
|
||||
t.Fatalf("mode = %s, want %s; diagnostics=%v", got.Mode, tt.wantMode, got.Diagnostics)
|
||||
}
|
||||
if tt.wantCode == "" {
|
||||
return
|
||||
}
|
||||
if len(got.Diagnostics) == 0 || got.Diagnostics[0].Code != tt.wantCode {
|
||||
t.Fatalf("diagnostics = %v, want first code %s", got.Diagnostics, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSVGFallbackImageOnlyPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">fallback</text></svg>`
|
||||
got, err := buildSVGFallbackImageOnlyPage(source, "boxcn_full_page")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`xmlns:slide="https://slides.bytedance.com/ns"`,
|
||||
`slide:role="slide"`,
|
||||
`slide:contract-version="svglide-authoring-contract/v1"`,
|
||||
`viewBox="0 0 1280 720"`,
|
||||
`<image slide:role="image" href="boxcn_full_page" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("image-only SVG missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if err := validateSVGlideSVG(got, "fallback.svg"); err != nil {
|
||||
t.Fatalf("image-only SVG should be native-valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSVGlideContractRootAttrsInjectsMissingVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
|
||||
got, err := ensureSVGlideContractRootAttrs(source)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) {
|
||||
t.Fatalf("contract version missing: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1" slide:contract-version`) {
|
||||
t.Fatalf("contract version duplicated: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSVGRasterizerUnavailableDiagnostic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := commandSVGRasterizer{
|
||||
command: "missing-svglide-renderer",
|
||||
lookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
err := r.CheckAvailable(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected renderer unavailable error")
|
||||
}
|
||||
diags := svglideDiagnosticsFromError(err)
|
||||
if len(diags) != 1 || diags[0].Code != svgDiagRendererUnavailable {
|
||||
t.Fatalf("diagnostics = %v, want renderer_unavailable", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSVGRasterizerArgvAndOutputSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "fake-resvg")
|
||||
argvFile := filepath.Join(dir, "argv.txt")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGV_FILE\"\nprintf png > \"$2\"\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake renderer: %v", err)
|
||||
}
|
||||
in := filepath.Join(dir, "page.svg")
|
||||
if err := os.WriteFile(in, []byte(`<svg/>`), 0o644); err != nil {
|
||||
t.Fatalf("write svg: %v", err)
|
||||
}
|
||||
r := commandSVGRasterizer{
|
||||
command: script,
|
||||
timeout: time.Second,
|
||||
maxOutputSize: 20,
|
||||
env: []string{"ARGV_FILE=" + argvFile},
|
||||
}
|
||||
|
||||
out, size, err := r.Rasterize(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected rasterize error: %v", err)
|
||||
}
|
||||
if size != int64(len("png")) {
|
||||
t.Fatalf("size = %d, want %d", size, len("png"))
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("output file missing: %v", err)
|
||||
}
|
||||
argv, err := os.ReadFile(argvFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read argv: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
|
||||
if len(lines) != 2 || lines[0] != in || lines[1] != out {
|
||||
t.Fatalf("argv = %q, want input and output path", string(argv))
|
||||
}
|
||||
|
||||
r.maxOutputSize = 2
|
||||
_, _, err = r.Rasterize(context.Background(), in)
|
||||
if err == nil {
|
||||
t.Fatal("expected output-size validation error")
|
||||
}
|
||||
diags := svglideDiagnosticsFromError(err)
|
||||
if len(diags) == 0 || diags[0].Code != svgDiagRasterOutputTooLarge {
|
||||
t.Fatalf("diagnostics = %v, want raster_output_too_large", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "supported shape rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported text foreignObject",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported server text foreignObject",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported server line role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><line slide:role="line" x1="0" y1="0" x2="100" y2="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported image href",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported image xlink href before rewrite",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" xlink:href="@./hero.png" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported path commands",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M1e-3 0 L80 0 H120 V40 C120 60 100 80 80 80 Q40 80 20 40 Z" fill="#123456"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "defs and metadata are ignored",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><defs><rect id="r"/></defs><metadata data-svglide-assets="true"><img src="boxcn_img"/></metadata><circle slide:role="shape" cx="50" cy="50" r="20"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "group container with role-fixed child",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g fill="#112233" transform="translate(10 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nested svg container with role-fixed child",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
|
||||
},
|
||||
{
|
||||
name: "group container ignores its own role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="shape"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nested svg container ignores its own role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg slide:role="shape" viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
|
||||
},
|
||||
{
|
||||
name: "style and nested defs are ignored",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.primary{fill:#123456}</style><g><defs><linearGradient id="g"><stop offset="0%" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient></defs></g><rect slide:role="shape" class="primary" x="0" y="0" width="100" height="60" fill="url(#g)"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "filter and shadow styles are preserved",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.card{filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2));box-shadow:0 8px 20px rgba(0,0,0,.18)}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><rect slide:role="shape" class="card" x="0" y="0" width="100" height="60" filter="url(#shadow)"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "foreignObject XHTML subtree is not role-validated",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml"><span>hello</span></div></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "foreignObject XHTML br is allowed",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml">hello<br />world</div></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "namespaced root is rejected with precise message",
|
||||
svg: `<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg:svg>`,
|
||||
wantErr: `root element must be non-namespaced <svg>`,
|
||||
},
|
||||
{
|
||||
name: "root child missing role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
|
||||
},
|
||||
{
|
||||
name: "group child missing role is rejected",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
|
||||
},
|
||||
{
|
||||
name: "unsupported text element remains rejected",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="shape" x="0" y="20">bad</text></svg>`,
|
||||
wantErr: `<text slide:role="shape"> is not supported by SVGlide`,
|
||||
},
|
||||
{
|
||||
name: "rect shape requires geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" height="60"/></svg>`,
|
||||
wantErr: `<rect slide:role="shape"> missing required attribute "width"`,
|
||||
},
|
||||
{
|
||||
name: "path shape requires d",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" fill="#123456"/></svg>`,
|
||||
wantErr: `<path slide:role="shape"> missing required attribute "d"`,
|
||||
},
|
||||
{
|
||||
name: "rect rejects percent geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="50%" height="60"/></svg>`,
|
||||
wantErr: `attribute "width" must be a number or px length`,
|
||||
},
|
||||
{
|
||||
name: "rect rejects calc geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="calc(10px)" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `attribute "x" must be a number or px length`,
|
||||
},
|
||||
{
|
||||
name: "container transform rejects percent argument",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g transform="translate(10% 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
wantErr: `transform translate() argument must be a number or px length`,
|
||||
},
|
||||
{
|
||||
name: "path rejects arc command",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20" fill="#123456"/></svg>`,
|
||||
wantErr: `unsupported path command or character "A"`,
|
||||
},
|
||||
{
|
||||
name: "path rejects smooth command",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 S10 10 20 20" fill="#123456"/></svg>`,
|
||||
wantErr: `unsupported path command or character "S"`,
|
||||
},
|
||||
{
|
||||
name: "plain metadata remains rejected",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
|
||||
wantErr: `<metadata> must include slide:role="shape", "image", "line", or "text"`,
|
||||
},
|
||||
{
|
||||
name: "foreignObject shape requires text type",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
|
||||
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
|
||||
},
|
||||
{
|
||||
name: "line role must be line tag",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="line" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<rect slide:role="line"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "text role must be foreignObject tag",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="text" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<rect slide:role="text"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "svg text role is not native yet",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="text" x="0" y="20">later</text></svg>`,
|
||||
wantErr: `<text slide:role="text"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "image role must be image tag",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,
|
||||
wantErr: `<rect slide:role="image"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "image requires href",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<image slide:role="image"> must include href`,
|
||||
},
|
||||
{
|
||||
name: "image requires geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" height="60"/></svg>`,
|
||||
wantErr: `<image slide:role="image"> missing required attribute "width"`,
|
||||
},
|
||||
{
|
||||
name: "image rejects external href",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="https://images.unsplash.com/photo.jpg" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<image slide:role="image"> must not use external http(s) or data href`,
|
||||
},
|
||||
{
|
||||
name: "unsupported role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="decor"/></svg>`,
|
||||
wantErr: `unsupported slide:role="decor"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateSVGlideSVG(tt.svg, "page.svg")
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSVGlideErrorJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
|
||||
got := extractSVGlideErrorJSON(err)
|
||||
if got["type"] != "svg_validation_error" {
|
||||
t.Fatalf("type = %v", got["type"])
|
||||
}
|
||||
if got["tag_name"] != "foreignObject" {
|
||||
t.Fatalf("tag_name = %v", got["tag_name"])
|
||||
}
|
||||
suffix := formatSVGlideErrorSuffix(err)
|
||||
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
|
||||
if !strings.Contains(suffix, want) {
|
||||
t.Fatalf("suffix = %q, want %q", suffix, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,11 +161,6 @@ func TestValidateProxyAddr(t *testing.T) {
|
||||
"http://gateway.docker.internal:16384",
|
||||
// trailing slash is tolerated
|
||||
"http://127.0.0.1:8080/",
|
||||
// https: any valid host (including remote, cross-machine) is allowed
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.mycorp.com",
|
||||
"https://sidecar.mycorp.com:8443",
|
||||
"https://sidecar.corp.internal:443/",
|
||||
}
|
||||
for _, addr := range valid {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
@@ -247,8 +242,6 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
"http://user@127.0.0.1:16384",
|
||||
"http://user:pass@127.0.0.1:16384",
|
||||
"http://127.0.0.1@attacker.com:16384",
|
||||
"https://x@evil.com",
|
||||
"https://user:pass@sidecar.mycorp.com",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
@@ -266,99 +259,23 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPSAllowed pins the contract: https addresses are
|
||||
// accepted, including a remote sidecar on another machine. TLS provides
|
||||
// confidentiality over the network and the HMAC signature provides
|
||||
// integrity/auth, so cross-machine https is supported.
|
||||
func TestValidateProxyAddr_HTTPSAllowed(t *testing.T) {
|
||||
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
|
||||
// rejected explicitly (not lumped into a generic "bad scheme" error) because
|
||||
// the interceptor hardcodes http and would silently downgrade an https URL
|
||||
// otherwise. The message must mention https so users understand why their
|
||||
// perfectly-looking config is refused.
|
||||
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://127.0.0.1:16384", // same-host over TLS
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.corp.internal:443",
|
||||
"https://sidecar.mycorp.com", // remote, no explicit port
|
||||
"https://sidecar.mycorp.com:8443",
|
||||
} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPRemoteRejected: plaintext http to a non-same-host
|
||||
// address stays rejected — a remote sidecar must use https.
|
||||
func TestValidateProxyAddr_HTTPRemoteRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"http://sidecar.mycorp.com",
|
||||
"http://sidecar.mycorp.com:8080",
|
||||
"http://10.0.0.1:16384",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
|
||||
continue
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "https") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should point to https/same-host, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyScheme: scheme is https only for https:// addresses, http otherwise.
|
||||
// Case-insensitive: HTTPS:// must resolve to https, otherwise a remote sidecar
|
||||
// would silently downgrade to plaintext http (see ProxyScheme doc).
|
||||
func TestProxyScheme(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://sidecar.mycorp.com": "https",
|
||||
"https://127.0.0.1:16384": "https",
|
||||
"http://127.0.0.1:16384": "http",
|
||||
"127.0.0.1:16384": "http",
|
||||
// case-insensitive scheme
|
||||
"HTTPS://sidecar.mycorp.com": "https",
|
||||
"Https://sidecar.mycorp.com": "https",
|
||||
"HtTp://127.0.0.1:16384": "http",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := ProxyScheme(in); got != want {
|
||||
t.Errorf("ProxyScheme(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_SchemeCaseInsensitive: mixed-case scheme must follow the
|
||||
// same policy as lower-case — HTTPS accepted (remote allowed), HTTP remote
|
||||
// rejected — so case can't be used to bypass the plaintext same-host rule.
|
||||
func TestValidateProxyAddr_SchemeCaseInsensitive(t *testing.T) {
|
||||
for _, addr := range []string{"HTTPS://sidecar.mycorp.com", "Https://sidecar.corp.internal:443"} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
for _, addr := range []string{"HtTp://sidecar.mycorp.com", "HTTP://10.0.0.1:16384"} {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_IPv6HTTPS pins IPv6 https forms.
|
||||
func TestValidateProxyAddr_IPv6HTTPS(t *testing.T) {
|
||||
for _, addr := range []string{"https://[::1]:443", "https://[::1]"} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_RejectsQueryFragment: a proxy address must not carry a
|
||||
// query or fragment, for either scheme.
|
||||
func TestValidateProxyAddr_RejectsQueryFragment(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://sidecar.mycorp.com?x=1",
|
||||
"https://sidecar.mycorp.com#frag",
|
||||
"http://127.0.0.1:16384?x=1",
|
||||
} {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
|
||||
if !strings.Contains(err.Error(), "https") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,10 +289,6 @@ func TestProxyHost(t *testing.T) {
|
||||
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
||||
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
||||
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
||||
// https forms (remote sidecar)
|
||||
{"https://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||
{"https://sidecar.mycorp.com:8443/", "sidecar.mycorp.com:8443"},
|
||||
{"HTTPS://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
// Package sidecar defines the wire protocol shared between the CLI client
|
||||
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
||||
// trusted environment). Communication uses HTTP for a same-host sidecar, or
|
||||
// HTTPS (TLS) for a remote sidecar.
|
||||
// trusted environment). Communication uses plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -104,31 +103,32 @@ func isSameHost(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// errNotSameHost is the shared error returned when a plaintext (http) sidecar
|
||||
// address does not resolve to the same physical host as the sandbox. Kept in
|
||||
// one place so tests can look for a stable marker.
|
||||
// errNotSameHost is the shared error returned when the sidecar address does
|
||||
// not resolve to the same physical host as the sandbox. Kept in one place so
|
||||
// tests can look for a stable marker.
|
||||
func errNotSameHost(addr string) error {
|
||||
return fmt.Errorf("invalid proxy address %q: a plaintext (http) sidecar must be "+
|
||||
"loopback (127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
|
||||
"(127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
"(localhost, host.docker.internal, host.containers.internal, "+
|
||||
"host.lima.internal, gateway.docker.internal). "+
|
||||
"For a remote sidecar on another machine, use an https:// address instead", addr)
|
||||
"The sidecar must run on the same physical machine as the sandbox — "+
|
||||
"cross-machine deployment is not a sidecar and is not supported", addr)
|
||||
}
|
||||
|
||||
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
||||
// Accepted formats:
|
||||
// - https://host[:port] (remote sidecar; cross-machine allowed)
|
||||
// - http://host:port (plaintext; same-host only)
|
||||
// - host:port (bare address, treated as plaintext http; same-host only)
|
||||
// - http://host:port
|
||||
// - host:port (bare address, treated as http)
|
||||
//
|
||||
// Scheme policy:
|
||||
// - https:// — any valid host is allowed, including a remote central sidecar
|
||||
// on another machine. TLS provides confidentiality over the untrusted
|
||||
// network; the per-request HMAC signature provides integrity/auth.
|
||||
// - http:// (or bare host:port) — plaintext, allowed only when the host is
|
||||
// loopback (127.0.0.1 / ::1) or a recognized same-host alias (a virtual
|
||||
// same-host bridge that stays on the physical machine). For a remote
|
||||
// sidecar, use an https:// address instead.
|
||||
// Host must be loopback or in sameHostAliases. The sidecar pattern is
|
||||
// inherently same-machine; cross-machine deployment is a different product
|
||||
// and is not supported by this feature.
|
||||
//
|
||||
// https:// is rejected because sidecar is a same-host pattern: loopback
|
||||
// and virtual same-host bridges don't traverse any untrusted medium, so
|
||||
// TLS adds no security. Cross-machine deployment is out of scope (see the
|
||||
// host constraint above), so there is no scenario today where https
|
||||
// provides a real benefit over http on loopback.
|
||||
//
|
||||
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
||||
// does not use basic auth, and the syntactic slot exists only as a phishing
|
||||
@@ -140,11 +140,11 @@ func ValidateProxyAddr(addr string) error {
|
||||
return fmt.Errorf("proxy address is empty")
|
||||
}
|
||||
|
||||
// Bare host:port (no scheme) — treated as plaintext http, so same-host only.
|
||||
// Bare host:port (no scheme) — validate as a net address.
|
||||
if !strings.Contains(addr, "://") {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http(s)://host[:port]", addr)
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
|
||||
}
|
||||
if host == "" || port == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
||||
@@ -159,47 +159,33 @@ func ValidateProxyAddr(addr string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
||||
}
|
||||
// userinfo (user:pass@) is rejected unconditionally (phishing vector).
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
|
||||
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
|
||||
"no security; cross-machine deployment is out of scope", addr)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return fmt.Errorf("invalid proxy address %q: query is not allowed", addr)
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return fmt.Errorf("invalid proxy address %q: fragment is not allowed", addr)
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
// Remote sidecar over TLS. Cross-machine is allowed: https provides
|
||||
// confidentiality over the network and the per-request HMAC signature
|
||||
// provides integrity/authentication, so a remote central sidecar is
|
||||
// supported without exposing credentials or signing material in clear.
|
||||
return nil
|
||||
case "http":
|
||||
// Plaintext: only safe on the same physical host (loopback or a virtual
|
||||
// same-host bridge). For a remote sidecar use an https:// address.
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http or https", addr)
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
||||
// Input is expected to be an http:// or https:// URL like
|
||||
// "http://127.0.0.1:16384" or "https://sidecar.mycorp.com".
|
||||
// Returns the host[:port] portion for URL rewriting.
|
||||
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
|
||||
// Returns the host:port portion for URL rewriting.
|
||||
func ProxyHost(authProxy string) string {
|
||||
// Strip scheme
|
||||
host := authProxy
|
||||
@@ -210,19 +196,3 @@ func ProxyHost(authProxy string) string {
|
||||
host = strings.TrimRight(host, "/")
|
||||
return host
|
||||
}
|
||||
|
||||
// ProxyScheme returns the URL scheme the CLI must use when routing to the
|
||||
// sidecar: "https" for a TLS (remote) sidecar, otherwise "http" (same-host
|
||||
// plaintext). Input is a value already accepted by ValidateProxyAddr.
|
||||
//
|
||||
// It parses the address (rather than a case-sensitive prefix check) so the
|
||||
// result stays consistent with ValidateProxyAddr, which relies on url.Parse
|
||||
// normalizing the scheme. Otherwise "HTTPS://host" — accepted as https by
|
||||
// ValidateProxyAddr — would silently downgrade to plaintext http here,
|
||||
// breaking the "remote must use TLS" boundary.
|
||||
func ProxyScheme(authProxy string) string {
|
||||
if u, err := url.Parse(authProxy); err == nil && strings.EqualFold(u.Scheme, "https") {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
@@ -114,23 +114,18 @@ export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to o
|
||||
|
||||
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
||||
|
||||
- Scheme must be `http://` / `https://` (or bare `host:port`, treated as
|
||||
plaintext http).
|
||||
- `https://<any-host>` is allowed, **including a remote sidecar on another
|
||||
machine**: TLS provides confidentiality over the network and the
|
||||
per-request HMAC signature provides integrity/authentication.
|
||||
- Plaintext `http://` (and bare `host:port`) is allowed **only same-host**:
|
||||
loopback (`127.0.0.1`, `::1`) or a recognized same-host alias
|
||||
(`localhost`, `host.docker.internal`, `host.containers.internal`,
|
||||
`host.lima.internal`, `gateway.docker.internal`). For a remote sidecar,
|
||||
use an `https://` address.
|
||||
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
|
||||
today because the interceptor does not yet perform TLS; a future PR that
|
||||
wires up real TLS will relax this.
|
||||
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
|
||||
same-host aliases: `localhost`, `host.docker.internal`,
|
||||
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
|
||||
The sidecar pattern is inherently same-machine; cross-machine deployment
|
||||
is a different product (auth broker / STS) with different security
|
||||
requirements (mTLS, cert rotation, per-client keys) and is not supported
|
||||
by this feature.
|
||||
- No path, query, fragment, or `user:pass@` in the URL.
|
||||
|
||||
> Note: this demo server itself terminates plain HTTP and is meant to run
|
||||
> locally. A production **remote** sidecar must terminate TLS (its own
|
||||
> `https://` endpoint, e.g. behind a load balancer or with a real
|
||||
> certificate); the CLI-side policy above is what enables pointing at it.
|
||||
|
||||
**How auto identity detection works in sidecar mode**: on every invocation the
|
||||
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
||||
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
||||
|
||||
@@ -43,28 +43,28 @@ metadata:
|
||||
| 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` |
|
||||
| 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` |
|
||||
| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 |
|
||||
| 创建/更新字段 | `+field-create` / `+field-update` | 必读 `lark-base-field-json.md`;公式读 `formula-field-guide.md`;lookup 读 `lookup-field-guide.md`;命令细节读 `lark-base-field-create.md` / `lark-base-field-update.md` |
|
||||
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 `lark-base-data-analysis-sop.md` |
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读对应 record reference 和 `lark-base-cell-value.md` |
|
||||
| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) |
|
||||
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) |
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 `lark-base-record-history-list.md`,只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 `lark-base-view-set-filter.md`;其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 `lark-base-data-analysis-sop.md` 和入口 `lark-base-data-query-guide.md`;完整 DSL 再读 `lark-base-data-query.md` |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 `formula-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 `lookup-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 `lark-base-form-detail.md` 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 `lark-base-form-submit.md` |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读对应 form-questions reference |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 `lark-base-form-detail.md`;删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 `lark-base-dashboard.md`;组件 `data_config` 读 `dashboard-block-data-config.md`;读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`;list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 `lark-base-role-guide.md`;角色 create/update 或解读完整配置再读权限 JSON SSOT `role-config.md`;系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
|
||||
## Base 心智模型
|
||||
|
||||
- Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。
|
||||
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
|
||||
- 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。
|
||||
- 一次性统计、筛选、TopN 优先用 `+data-query` 或临时视图;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
|
||||
- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
|
||||
- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。
|
||||
- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。
|
||||
- 跨表场景必须读取目标表结构;link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。
|
||||
@@ -79,21 +79,21 @@ metadata:
|
||||
|
||||
## 查询与统计规则
|
||||
|
||||
涉及查询、统计或判断结论时,先阅读 `references/lark-base-data-analysis-sop.md`,并遵守:
|
||||
涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守:
|
||||
|
||||
1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
|
||||
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉明细到本地上下文再手工筛选排序。
|
||||
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉原始记录到本地上下文再手工筛选排序。
|
||||
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
|
||||
4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。
|
||||
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
|
||||
6. 一次性分析优先用 `+data-query` 或临时视图;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。
|
||||
7. `+data-query` 返回聚合结果,不返回原始记录明细;需要输出实体字段时,用聚合结果中的业务 key 或 record_id 再走 record 路径回查。
|
||||
6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。
|
||||
7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。
|
||||
|
||||
## 写入前置规则
|
||||
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 `lark-base-field-json.md`;涉及 `formula` / `lookup` 时必须读对应 guide。
|
||||
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
||||
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
|
||||
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
|
||||
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
|
||||
@@ -105,7 +105,7 @@ metadata:
|
||||
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 临时视图适合一次性筛选/排序后读取;如果筛选结果对用户后续查看有价值,应保留为持久视图并说明名称和用途。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
|
||||
## Token 与链接
|
||||
|
||||
@@ -125,9 +125,9 @@ metadata:
|
||||
|
||||
## Dashboard / Workflow / Role
|
||||
|
||||
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 `dashboard-block-data-config.md`,组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。
|
||||
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`;enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
|
||||
- Role 的复杂点是权限 JSON。角色操作先读入口 `lark-base-role-guide.md`;`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT `role-config.md`。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
|
||||
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。
|
||||
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
|
||||
- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
|
||||
|
||||
## 常见恢复
|
||||
|
||||
@@ -136,9 +136,9 @@ metadata:
|
||||
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token |
|
||||
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API |
|
||||
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 |
|
||||
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 `lark-base-cell-value.md` 构造 CellValue |
|
||||
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |
|
||||
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
|
||||
| formula / lookup 创建失败 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
|
||||
| formula / lookup 创建失败 | 先读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md),再按 guide 重建请求 |
|
||||
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
|
||||
| `1254104` | 批量超过 200,分批调用 |
|
||||
| `1254291` | 并发写冲突,串行写入并在批次间短暂等待 |
|
||||
@@ -146,15 +146,15 @@ metadata:
|
||||
|
||||
## 保留 Reference
|
||||
|
||||
- `lark-base-data-analysis-sop.md`:查询/统计/全局结论的选路 SOP
|
||||
- `lark-base-data-query-guide.md` / `lark-base-data-query.md`:聚合查询入口 fewshot 与 DSL SSOT
|
||||
- `lark-base-cell-value.md`:记录 CellValue 构造
|
||||
- `lark-base-field-json.md`:字段 JSON 构造
|
||||
- `formula-field-guide.md` / `lookup-field-guide.md`:公式与 lookup 字段
|
||||
- `lark-base-field-create.md` / `lark-base-field-update.md`:字段创建/更新命令级补充
|
||||
- `lark-base-record-upsert.md` / `lark-base-record-batch-create.md` / `lark-base-record-batch-update.md` / `lark-base-record-history-list.md`:记录写入 JSON 与历史返回解释
|
||||
- `lark-base-view-set-filter.md`:视图筛选 JSON
|
||||
- `lark-base-form-detail.md` / `lark-base-form-submit.md` / `lark-base-form-questions-create.md` / `lark-base-form-questions-update.md`:表单详情、提交和复杂 JSON
|
||||
- `lark-base-dashboard.md` / `dashboard-block-data-config.md` / `lark-base-dashboard-block-get-data.md`:仪表盘、组件配置与图表结果协议
|
||||
- `lark-base-workflow-guide.md` / `lark-base-workflow-schema.md`:workflow 入口与 steps JSON SSOT
|
||||
- `lark-base-role-guide.md` / `role-config.md`:角色入口与权限 JSON SSOT
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
- [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT
|
||||
|
||||
@@ -6,14 +6,15 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、
|
||||
|
||||
- `+data-query`: entry guide [lark-base-data-query-guide.md](lark-base-data-query-guide.md), full DSL SSOT [lark-base-data-query.md](lark-base-data-query.md)
|
||||
- 视图筛选: [lark-base-view-set-filter.md](lark-base-view-set-filter.md)
|
||||
- 视图排序/投影、记录读取: 先 get/list 现状,确认字段 ID、字段名、分页和投影范围
|
||||
- 记录读取: `+record-list` / `+record-search` / `+record-get`,先确认字段 ID、字段名、分页和投影范围
|
||||
|
||||
## 0. Hard Rules
|
||||
|
||||
- 全局问题不能用默认 `+record-list --limit N` 片面地回答。
|
||||
- `jq` / shell / 本地代码是在个人电脑或当前运行环境中处理已返回数据,只适合小范围结果;超过 200 行默认不推荐本地统计、排序或求极值,应改用 Base 云端查询服务的 filter/sort/aggregate。
|
||||
- “最高、最低、最新、最早、Top、Bottom、总数、全部、异常、最大、最小、最多、最少、优先级最高”等全局语义,必须在 Base 云端查询服务中完成筛选、排序或聚合。
|
||||
- `+record-search` 用于关键词检索字段的展示文本;可搜多类字段,但匹配的是文本表示(如人员命中 name),不要用它替代金额、状态、日期、空值等结构化条件。
|
||||
- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`。
|
||||
- `+record-search` 用于关键词检索字段的展示文本;金额、状态、日期、空值、关联等结构化条件继续用 `--filter-json` 表达。
|
||||
- 不要依赖已有视图,除非用户明确指定该视图,或你已读取并验证其 filter/sort/projection 符合当前问题。
|
||||
- 交付输出必须使用用户可读的真实字段值;内部 ID、`record_id`、关联记录 ID、open_id、编码字段只可作为连接键或定位键,不能替代最终输出,除非用户明确要求输出这些键值。
|
||||
- 每次读取必须做最小投影,并包含后续解释、回查或写入需要的业务 key。
|
||||
@@ -22,39 +23,160 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、
|
||||
|
||||
| 用户意图 | 首选路径 | 关键规则 |
|
||||
| --- | --- | --- |
|
||||
| 看几条、预览、示例 | `+record-list --limit N` | 保持局部语义;不要推广为全局结论 |
|
||||
| 看几条、预览、示例 | `+record-list --limit N --field-id ...` | 保持局部语义;不要推广为全局结论 |
|
||||
| 已知 `record_id` | `+record-get` | 直接读取;不要 search/list 反查 |
|
||||
| 明确关键词 | `+record-search` | 按字段展示文本命中;使用 `search_fields` 限定匹配范围、`select_fields` 投影降低返回内容 token 量;不要把文本检索当作结构化关联解析 |
|
||||
| 按条件找明细记录 | 先创建临时视图设置筛选和可见字段,再用 `+record-list --view-id` 读取 | 条件字段来自 `+field-list`;不要先读全表再本地过滤 |
|
||||
| 排序 / TopN 原始记录 | 临时视图 filter/sort/projection -> `+record-list --view-id --limit N` | 最高/最新降序,最低/最早升序 |
|
||||
| 明确关键词 | `+record-search --keyword ... --search-field ... --field-id ...` | 必须显式指定 `--search-field`;可叠加 `--filter-json` |
|
||||
| 按条件找原始记录 | `+record-list --filter-json ...` | `filter-json` 与视图筛选结构一致,支持文本、数字、日期、选项、人员、群组、关联等值 |
|
||||
| 排序 / TopN 原始记录 | `+record-list --filter-json ... --sort-json ... --limit N` | 最高/最新用 `desc:true`,最低/最早用 `desc:false`;数组顺序表达优先级;最多 10 个排序条件 |
|
||||
| 聚合 / 分组 / 分组排序 | `+data-query` | 使用 filters/dimensions/measures/sort/limit |
|
||||
| 聚合后输出实体字段 | `+data-query` 得到业务 key -> record 路径回查明细 | `+data-query` 不返回原始记录或 link 明细;聚合结果中的 key 需要再解析成用户要求字段 |
|
||||
| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段,并在回答用结果中合并展示 |
|
||||
| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询优先沉淀为持久视图 |
|
||||
| 聚合后输出逐条记录 | `+data-query` 得到业务 key 或候选字段组合 -> `+record-list --filter-json` / `+record-get` 回查 | `+data-query` 维度行按字段组合去重且不返回 `record_id` |
|
||||
| 多表 / 多跳关联 | 以候选数最小的事实表为驱动表,沿业务 key 或 link `record_id` 逐跳回查 | 读出 link 单元格里的关联 `record_id` 后,到被关联表批量 `+record-get` 展示字段 |
|
||||
| 查询后写入 / 视图化 | 先用本 SOP 得到可复核的目标记录 id 集合 | 再进入记录写入或视图配置;高价值可复用查询可沉淀为持久视图 |
|
||||
|
||||
## 2. Execution Patterns
|
||||
|
||||
### 2.1 结构化明细与 TopN
|
||||
### 2.1 结构化原始记录与 TopN
|
||||
|
||||
使用视图路径:
|
||||
使用 `+record-list` 的 filter/sort 路径:
|
||||
|
||||
1. `+field-list` 确认筛选字段、排序字段、展示字段、业务 key。
|
||||
2. `+view-create` 创建 grid 视图。
|
||||
3. 设置 filter/sort/visible fields。
|
||||
4. `+record-list --view-id <view_id> --limit <N>` 读取结果。
|
||||
2. 筛选只用 `--filter-json` 或 `--filter-json @file`。
|
||||
3. 排序用 `--sort-json`。
|
||||
4. `--field-id` 做最小投影,`--limit` 控制返回数量。
|
||||
|
||||
不要从未筛选、未排序的全表输出中手动挑选。一次性查询可用临时视图;如果这个筛选/排序结果对用户后续查看有价值,应保留为持久视图,不要删除,并告知用户视图名称和用途。筛选 JSON 见 view-set-filter reference;排序和可见字段配置先读取现状,再按目标字段、顺序和排序方向改写。
|
||||
Example: string/number 条件 + TopN:
|
||||
|
||||
### 2.2 聚合分析与 TopN
|
||||
```bash
|
||||
lark-cli base +record-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"],["Score",">=",80]]}' \
|
||||
--sort-json '[{"field":"Updated","desc":true}]' \
|
||||
--field-id Name \
|
||||
--field-id Title \
|
||||
--field-id Score \
|
||||
--limit 20
|
||||
```
|
||||
|
||||
Example: 复杂筛选从文件读取:
|
||||
|
||||
```bash
|
||||
lark-cli base +record-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--filter-json @filter.json \
|
||||
--sort-json '[{"field":"Priority","desc":true}]' \
|
||||
--field-id Name \
|
||||
--field-id Tags \
|
||||
--limit 50
|
||||
```
|
||||
|
||||
`filter-json` 与视图筛选结构一致。下面只列常用 fewshot;字段类型、operator、value 形状拿不准,或需要人员、群组、关联、空值、地理位置、formula / lookup 等完整筛选时,先读 [lark-base-view-set-filter.md](lark-base-view-set-filter.md),再把同样的 filter JSON 传给 `--filter-json`。
|
||||
|
||||
文本 `==`:字段值等于目标文本。
|
||||
```json
|
||||
{"logic":"and","conditions":[["Title","==","Launch plan"]]}
|
||||
```
|
||||
|
||||
文本包含 / like:文本字段包含目标片段;operator 写 `intersects`。
|
||||
```json
|
||||
{"logic":"and","conditions":[["Title","intersects","urgent"]]}
|
||||
```
|
||||
|
||||
数字 `==`:字段值等于目标数字。
|
||||
```json
|
||||
{"logic":"and","conditions":[["Score","==",95]]}
|
||||
```
|
||||
|
||||
日期 `==`:字段值等于目标日期;datetime / created_at / updated_at 用 `ExactDate(...)`。
|
||||
```json
|
||||
{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}
|
||||
```
|
||||
|
||||
选项 `==`:字段值匹配单个选项;选项值使用选项名数组,单个选项也写数组。
|
||||
```json
|
||||
{"logic":"and","conditions":[["Priority","==",["P0"]]]}
|
||||
```
|
||||
|
||||
选项 `intersects`:字段值与给定选项集合有交集,常用于多选或“命中任一选项”。
|
||||
```json
|
||||
{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}
|
||||
```
|
||||
|
||||
`--sort-json` 传排序数组,数组顺序就是优先级,`desc:true` 为降序,`desc:false` 为升序,最多 10 个排序条件。
|
||||
|
||||
### 2.2 关键词检索后叠加结构化条件
|
||||
|
||||
使用 `+record-search` 做关键词命中,结构化条件仍用 `--filter-json` 下推:
|
||||
|
||||
```bash
|
||||
lark-cli base +record-search \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--keyword Alice \
|
||||
--search-field Name \
|
||||
--filter-json '{"logic":"and","conditions":[["Status","!=","Done"]]}' \
|
||||
--sort-json '[{"field":"Updated","desc":true}]' \
|
||||
--field-id Name \
|
||||
--field-id Status \
|
||||
--limit 20
|
||||
```
|
||||
|
||||
不要把 `+record-search` 当成金额、状态、日期、空值、关联字段的结构化筛选入口;这些条件继续写成 `--filter-json`。
|
||||
|
||||
### 2.3 聚合分析与 TopN
|
||||
|
||||
使用 `+data-query`:
|
||||
|
||||
- 让 Base 云端查询服务完成 filters、dimensions、measures、sort、pagination.limit。
|
||||
- `pagination.limit` 是 Base 云端查询服务中的聚合结果限制,不是本地分页扫描。
|
||||
- 需要输出明细或用户可读字段时,先拿业务 key,再用 record 路径精确回查。
|
||||
- `pagination.limit` 是 Base 云端查询服务中的结果限制,不是本地分页扫描。
|
||||
- 常用聚合 fewshot 先读 [lark-base-data-query-guide.md](lark-base-data-query-guide.md);字段类型、日期 value、DSL shape 以 [lark-base-data-query.md](lark-base-data-query.md) 为准。
|
||||
- `+data-query` 可返回聚合结果或维度字段行;维度字段行按字段组合去重且不返回 `record_id`,不能当逐条原始记录结果使用。
|
||||
- 需要输出逐条记录、记录定位或完整行级字段时,先用 `+data-query` 得到业务 key、分组值或候选字段组合,再用 `+record-list --filter-json` / `+record-get` 回查。
|
||||
|
||||
### 2.3 关系查询与回查
|
||||
Example: 分组计数:
|
||||
|
||||
```bash
|
||||
lark-cli base +data-query \
|
||||
--base-token <base_token> \
|
||||
--dsl '{"datasource":{"type":"table","table":{"tableId":"<table_id>"}},"dimensions":[{"field_name":"Status","alias":"status"}],"measures":[{"field_name":"Status","aggregation":"count","alias":"count"}],"shaper":{"format":"flat"}}'
|
||||
```
|
||||
|
||||
Example: 过滤后汇总并取 TopN:
|
||||
|
||||
```bash
|
||||
lark-cli base +data-query \
|
||||
--base-token <base_token> \
|
||||
--dsl '{"datasource":{"type":"table","table":{"tableId":"<table_id>"}},"dimensions":[{"field_name":"Owner","alias":"owner"}],"measures":[{"field_name":"Amount","aggregation":"sum","alias":"total_amount"}],"filters":{"type":1,"conjunction":"and","conditions":[{"field_name":"Status","operator":"is","value":["Done"]}]},"sort":[{"field_name":"total_amount","order":"desc"}],"pagination":{"limit":10},"shaper":{"format":"flat"}}'
|
||||
```
|
||||
|
||||
### 2.4 视图化与复用
|
||||
|
||||
一次性查询先用 `+record-list` / `+record-search` 的 filter/sort 验证。需要用户长期打开、共享或复用时,再把同一套 filter/sort 沉淀为视图。
|
||||
|
||||
Example: 将已验证的筛选排序写入视图:
|
||||
|
||||
```bash
|
||||
lark-cli base +view-set-filter \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--view-id <view_id> \
|
||||
--json @filter.json
|
||||
|
||||
lark-cli base +view-set-sort \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--view-id <view_id> \
|
||||
--json '{"sort_config":[{"field":"Priority","desc":true}]}'
|
||||
```
|
||||
|
||||
手动配置和视图配置的优先级:
|
||||
|
||||
1. `--filter-json` 覆盖 `--view-id` 保存的 view filter JSON。
|
||||
2. `--sort-json` 覆盖 `--view-id` 保存的 view sort config。
|
||||
3. 没有手动 filter/sort 时,`--view-id` 使用视图自身保存的 filter/sort。
|
||||
|
||||
### 2.5 关系查询与回查
|
||||
|
||||
- link 单元格通常是关联表 `record_id` 数组,不是用户可读内容,只是连接键。
|
||||
- 先用 `+field-list` 确认 link 字段的 `link_table`、业务唯一键和展示字段。
|
||||
@@ -71,17 +193,17 @@ Base 数据查询与分析任务的执行契约。覆盖记录读取、筛选、
|
||||
|
||||
- `+record-list` 默认页、固定 `--limit`、本地 `jq`、shell 管道、手工浏览输出,都只覆盖已读取范围;超过 200 行不要把本地处理当作推荐路径。
|
||||
- `has_more=true`、存在下一页 offset/page token、或返回行数等于 page size,都表示可能还有未读取数据。
|
||||
- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。
|
||||
- 必须全量导出时,按 CLI 分页语义串行翻页;不要并发调用 `+record-list`。
|
||||
- 对全局问题,只有 Base 云端查询服务已经通过 filter/sort/aggregate 收敛目标范围,或 `+data-query` 已在云端完成聚合、排序和限制时,才可以用有限返回形成结论。
|
||||
- 必须全量导出时,按 `+record-list` 分页语义串行翻页;不要并发调用 `+record-list`。
|
||||
|
||||
## 4. Final Answer Check
|
||||
|
||||
形成交付输出前必须能确认:
|
||||
|
||||
- 问题范围是局部样例、单点定位、全局明细、聚合分析、多表关联,还是查询后写入。
|
||||
- 问题范围是局部样例、单点定位、全局原始记录、聚合分析、多表关联,还是查询后写入。
|
||||
- 筛选、排序、聚合是否发生在 Base 云端查询服务中,而不是本地 `jq` / shell 中。
|
||||
- 如果使用 `jq` / shell,本地输入是否是 200 行以内的小范围结果;超过 200 行是否已改用 Base 云端查询服务查询。
|
||||
- 如果使用 `+record-list`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。
|
||||
- 如果使用 `+record-list` / `+record-search`,是否处理了 `has_more`,且投影包含业务 key 和解释字段。
|
||||
- 如果涉及关系查询,是否按 `record_id` 或业务 key 精确回查,交付输出是否来自关联表真实字段。
|
||||
- 交付输出能追溯到表、字段、筛选条件、排序/聚合条件和连接键。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Use `+data-query` when the user asks for server-side:
|
||||
- sorted Top N or Bottom N
|
||||
- global statistical conclusions
|
||||
|
||||
Do not use `+data-query` for raw record details. Use record commands for row-level output.
|
||||
`+data-query` can return dimension field rows, but those rows are grouped by dimension values and do not include `record_id`. Use `+record-list`, `+record-search`, or `+record-get` for row-level output, record identity, or full raw record details.
|
||||
|
||||
## Common Fewshots
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ lark-cli base +data-query \
|
||||
"shaper": {"format": "flat"}
|
||||
}'
|
||||
|
||||
# 聚合后如需读取明细,先让 data-query 返回可回查的业务 key
|
||||
# 聚合或维度查询后如需读取逐条记录,先让 data-query 返回可回查的业务 key
|
||||
lark-cli base +data-query \
|
||||
--base-token MAGObxxxxx \
|
||||
--dsl '{
|
||||
@@ -419,16 +419,16 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
|
||||
## 与记录读取组合
|
||||
|
||||
`+data-query` 不返回原始记录或 link 字段明细。需要输出聚合结果对应的原始记录字段、展示值或关联表字段时,按以下方式组合:
|
||||
`+data-query` 可返回聚合结果,也可在只传 `dimensions` 时返回维度字段行;这些维度行按字段组合去重,不包含 `record_id`,不能等同于逐条原始记录。需要输出聚合结果对应的原始记录字段、展示值、记录定位信息或关联表字段时,按以下方式组合:
|
||||
|
||||
1. 用 `+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN,得到业务 key、分组值或候选范围。
|
||||
2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取明细字段。
|
||||
3. 如果拿到的是结构化业务 key(例如编号、状态、日期、金额等),优先创建临时视图做精确过滤后再 `+record-list --view-id` 读取;不要用 `+record-search` 代替结构化条件。
|
||||
1. 用 `+data-query` 在 Base 云端查询服务中完成全局筛选、分组、聚合、排序和 TopN,得到业务 key、分组值或候选字段组合。
|
||||
2. 如果已经拿到候选记录的 `record_id`,用 `+record-get` 读取逐条记录字段。
|
||||
3. 如果拿到的是结构化业务 key(例如编号、状态、日期、金额等),用 `+record-list --filter-json` 做精确过滤后读取;不要用 `+record-search` 代替结构化条件。
|
||||
4. 只有候选条件本身是文本展示值关键词时,才使用 `+record-search`,并用 `search_fields` 限定范围、`select_fields` 做投影。
|
||||
5. 若候选记录包含 link 字段,提取关联 `record_id` 后到关联表用 `+record-get` 批量读取展示字段。
|
||||
6. 最终回答业务字段,不要把内部 `record_id` 当作用户可读答案。
|
||||
|
||||
不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量明细导出时回到 data analysis SOP 的 record 分页规则。
|
||||
不要把 `data-query pagination.limit` 理解为分页扫描;它只限制 Base 云端查询服务返回的聚合结果行数,不支持 offset。需要全量原始记录导出时回到 data analysis SOP 的 `+record-list` 分页规则。
|
||||
|
||||
## 坑点
|
||||
|
||||
@@ -447,6 +447,6 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、record 明细回查和关系查询 SOP
|
||||
- [lark-base-data-analysis-sop.md](lark-base-data-analysis-sop.md) — 查询范围、选路、下推、分页、`+record-list` / `+record-search` 回查和关系查询 SOP
|
||||
- [lark-base-cell-value.md](lark-base-cell-value.md) — CellValue 格式规范
|
||||
- [lark-base-field-json.md](lark-base-field-json.md) — 字段类型与 JSON 结构
|
||||
|
||||
@@ -15,6 +15,7 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md`、`svg-protocol.md` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
@@ -24,15 +25,19 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
**CRITICAL — 走 XML 创建/编辑路径时,生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径(`slides +create-svg`)时,MUST 改读 [svg-protocol.md](references/svg-protocol.md),不要求读取 XML schema。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVG:root `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。**
|
||||
|
||||
**CRITICAL — 高质量 SVG deck 生成时,MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan,再定义布局盒,给 `foreignObject` 文本留足安全高度,默认必须使用真实图片资产(本地 `@./path` 或 file token),相邻页面要显著换版式;调用 API 前必须跑本地 preflight(优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)),live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py),SVG 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
@@ -77,7 +82,7 @@ lark-cli auth login --domain slides
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md);SVG 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
@@ -99,7 +104,7 @@ lark-cli auth login --domain slides
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
@@ -123,7 +128,8 @@ lark-cli auth login --domain slides
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要使用版权状态不明的图片、logo、截图或素材;图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产,并在产物说明或素材清单中记录来源、授权/许可类型、原始 URL 和是否需要署名。
|
||||
- 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
@@ -132,6 +138,7 @@ lark-cli auth login --domain slides
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| AI 生成 SVGlide SVG(希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
@@ -160,10 +167,10 @@ Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行;SVG 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML,仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
@@ -259,6 +266,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+create-svg`](references/lark-slides-create-svg.md) | 从一个或多个 SVGlide SVG 文件创建 PPT,按 `--file` 顺序逐页调用现有 `/slide` 路由 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
|
||||
@@ -272,19 +280,20 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加;AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
8. **图片只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:XML 路径使用 `<img src="...">`;SVG 路径使用 `<image slide:role="image" href="...">`。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src` / `href`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 权限速查
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) |
|
||||
| `slides +create-svg` | `slides:presentation:create`, `slides:presentation:write_only`, `docs:document.media:upload` |
|
||||
| `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `slides +replace-slide` | `slides:presentation:update`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `xml_presentations.get` | `slides:presentation:read` |
|
||||
@@ -293,4 +302,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
> **注意**:XML 路径如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准;SVG 路径以 [svg-protocol.md](references/svg-protocol.md) 为准。
|
||||
|
||||
## SVG 排障
|
||||
|
||||
`slides +create-svg` 失败时,优先查看错误中是否包含 `svglide_error` 或服务端 `SVGLIDE_ERROR_JSON:` marker。常见修复:
|
||||
|
||||
- `svg_validation_error`:按 [svg-protocol.md](references/svg-protocol.md) 修正 root `<svg>`、`xmlns:slide`、`slide:role` 或不支持元素。
|
||||
- 图片不显示:确认 `<image>` 使用 canonical `href="file_token"`,不要保留 `xlink:href`;本地图片用 `href="@./image.png"` 让 CLI 上传,或用 `--assets assets.json` 提供 token 映射。
|
||||
- 有 file token 仍失败:确认 SVG 内存在 transport metadata:`<metadata data-svglide-assets="true"><img src="同一个 file_token" /></metadata>`;`+create-svg` 会自动注入,手写 SVG 时不要删除。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user