mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
2 Commits
feat/short
...
feat/sidec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52dc09af95 | ||
|
|
07da0c8090 |
@@ -65,23 +65,10 @@ 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|shortcuts/drive/)
|
||||
- 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)
|
||||
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:
|
||||
@@ -107,23 +94,6 @@ 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,31 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -989,7 +964,6 @@ 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
|
||||
|
||||
@@ -527,10 +527,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
@@ -557,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.GetService()) {
|
||||
domains[sc.GetService()] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
@@ -580,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
|
||||
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
|
||||
// Empty AuthTypes defaults to ["user"].
|
||||
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
|
||||
@@ -64,13 +64,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
svc := sc.GetService()
|
||||
if !seen[svc] {
|
||||
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
|
||||
dm := buildDomainMeta(svc, lang)
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
seen[svc] = true
|
||||
seen[sc.Service] = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := &common.Shortcut{AuthTypes: nil}
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected default to support 'user'")
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected to support 'user'")
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
|
||||
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
sc := common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
if shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected bot-only to NOT support 'user'")
|
||||
}
|
||||
|
||||
@@ -341,9 +341,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -383,9 +380,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -425,11 +419,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -518,10 +507,5 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.GetService() != "" {
|
||||
seen[s.GetService()] = true
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.GetCommand(),
|
||||
Method: sc.Command,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
@@ -148,12 +148,11 @@ func diagBuild(domains []string) diagOutput {
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range authTypes {
|
||||
for _, a := range sc.AuthTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
@@ -154,8 +154,8 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
|
||||
21
cmd/root.go
21
cmd/root.go
@@ -48,6 +48,20 @@ 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.
|
||||
@@ -241,13 +255,6 @@ 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)
|
||||
|
||||
@@ -155,30 +155,7 @@ 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` — 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[]`.
|
||||
belongs in a typed `*errs.XxxError`.
|
||||
|
||||
## Consumers
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
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)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
|
||||
// cross-field semantic failures need their own subtype here; per-field
|
||||
// failures (required missing / enum invalid / typed-primitive format) reuse
|
||||
// SubtypeInvalidArgument.
|
||||
const (
|
||||
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
|
||||
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
|
||||
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutSubtypes_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Subtype
|
||||
want string
|
||||
}{
|
||||
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
|
||||
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
|
||||
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.got) != tt.want {
|
||||
t.Errorf("got %q, want %q", string(tt.got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -61,22 +61,8 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
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"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
@@ -136,11 +122,6 @@ 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,71 +558,6 @@ 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,11 +4,12 @@
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// 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.
|
||||
// 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.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -46,15 +47,17 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
@@ -130,8 +133,13 @@ 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
|
||||
req.URL.Scheme = "http"
|
||||
// 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
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
|
||||
@@ -7,11 +7,13 @@ package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
@@ -97,6 +99,54 @@ 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,7 +4,9 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -164,9 +166,42 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenResult{Token: token}, nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// 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 HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
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"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// Content safety scanning mode
|
||||
|
||||
@@ -129,7 +129,6 @@ 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
|
||||
@@ -232,22 +231,6 @@ 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
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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") }
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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,28 +170,6 @@ 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,10 +61,6 @@ 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,10 +270,6 @@ 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,39 +306,6 @@ 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)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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,287 +593,3 @@ 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,8 +106,6 @@ 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.46",
|
||||
"version": "1.0.45",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -71,29 +71,6 @@ 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"}},
|
||||
@@ -122,33 +99,6 @@ 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"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
@@ -990,121 +990,12 @@ 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,39 +254,35 @@ 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 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`,
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
"for keyword search only",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"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",
|
||||
"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",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
"inventing search JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -611,7 +607,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`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`,
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -889,11 +885,11 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate == nil {
|
||||
t.Fatalf("record list validate should reject invalid query flags before dry-run")
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
@@ -904,58 +900,6 @@ 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,8 +22,6 @@ 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(),
|
||||
@@ -31,21 +29,10 @@ 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.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
"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.",
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
|
||||
@@ -217,9 +217,6 @@ 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).
|
||||
@@ -240,12 +237,8 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
var body map[string]interface{}
|
||||
if strings.TrimSpace(runtime.Str("json")) != "" {
|
||||
body, _ = recordSearchJSONBody(runtime)
|
||||
} else {
|
||||
body, _ = recordSearchFlagBody(runtime)
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search").
|
||||
Body(body).
|
||||
@@ -395,9 +388,6 @@ 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
|
||||
@@ -430,13 +420,8 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
var body map[string]interface{}
|
||||
var err error
|
||||
if strings.TrimSpace(runtime.Str("json")) != "" {
|
||||
body, err = recordSearchJSONBody(runtime)
|
||||
} else {
|
||||
body, err = recordSearchFlagBody(runtime)
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
// 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,34 +20,21 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(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"},
|
||||
{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},
|
||||
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 {
|
||||
return validateRecordSearchFlags(runtime)
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordSearch,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ChatID is a typed Lark chat identifier with the "oc_" prefix.
|
||||
// Satisfies common.Validatable.
|
||||
type ChatID string
|
||||
|
||||
// ValidateValue checks the oc_ prefix and trims whitespace. Empty values are
|
||||
// rejected here even though required-ness is enforced by the binder; this
|
||||
// keeps the type safe to call as a standalone validator.
|
||||
func (id ChatID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := strings.TrimSpace(string(id))
|
||||
if s == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "chat ID is required",
|
||||
Hint: "pass --chat-id oc_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, "oc_") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "invalid chat ID format: expected oc_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestChatID_ValidatePass(t *testing.T) {
|
||||
id := ChatID("oc_abc123")
|
||||
if err := id.ValidateValue(nil, "chat-id"); err != nil {
|
||||
t.Errorf("oc_ prefix should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatID_ValidateReject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"wrong prefix", "ou_abc"},
|
||||
{"random", "abc123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ChatID(tt.v).ValidateValue(nil, "chat-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "chat-id" {
|
||||
t.Errorf("Param = %q, want chat-id", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MediaInput is the tri-state media-field value used by im image/file/video/
|
||||
// audio flags: URL, "img_xxx"/"file_xxx" key, or cwd-relative local path.
|
||||
// URL and key forms bypass path safety checks; local paths go through the
|
||||
// same SafePath rules. Does not emit absolute paths in hints (log safety).
|
||||
type MediaInput string
|
||||
|
||||
// IsURL reports whether the value looks like an http(s) URL.
|
||||
func (m MediaInput) IsURL() bool {
|
||||
s := string(m)
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
}
|
||||
|
||||
// IsMediaKey reports whether the value is an already-uploaded media key.
|
||||
func (m MediaInput) IsMediaKey() bool {
|
||||
s := string(m)
|
||||
return strings.HasPrefix(s, "img_") || strings.HasPrefix(s, "file_")
|
||||
}
|
||||
|
||||
func (m MediaInput) ValidateValue(rt *common.RuntimeContext, flagName string) error {
|
||||
if string(m) == "" {
|
||||
return nil
|
||||
}
|
||||
if m.IsURL() || m.IsMediaKey() {
|
||||
return nil
|
||||
}
|
||||
return SafePath(m).ValidateValue(rt, flagName)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaInput_AcceptURL(t *testing.T) {
|
||||
for _, v := range []string{"https://example.com/x.png", "http://a.b/y"} {
|
||||
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("URL %q should pass: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_AcceptKey(t *testing.T) {
|
||||
for _, v := range []string{"img_abc123", "file_xyz"} {
|
||||
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("key %q should pass: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_RejectAbsolutePath(t *testing.T) {
|
||||
if err := MediaInput("/etc/passwd").ValidateValue(nil, "image"); err == nil {
|
||||
t.Fatal("absolute path must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_AcceptRelativePath(t *testing.T) {
|
||||
if err := MediaInput("./pic.png").ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("relative path should pass: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SafePath is a strict cwd-relative file path. Absolute paths and ".."
|
||||
// segments are rejected. Does NOT emit absolute path back to stderr in any
|
||||
// hint (log safety).
|
||||
type SafePath string
|
||||
|
||||
func (p SafePath) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := string(p)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(s) {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must be cwd-relative; absolute paths are rejected",
|
||||
Hint: "use a relative path like ./file.txt",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
// Check the RAW input for ".." segments before filepath.Clean collapses
|
||||
// them — Clean turns "a/../b" into "b", which would otherwise hide a
|
||||
// parent-traversal segment the user actually typed. Split on both
|
||||
// separators so "\.." on Windows-style input is caught too.
|
||||
for _, seg := range strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||
if seg == ".." {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must not contain '..' segments",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
}
|
||||
clean := filepath.Clean(s)
|
||||
if strings.HasPrefix(clean, "..") || strings.Contains(clean, "/../") || strings.Contains(clean, `\..\`) {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must not contain '..' segments",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestSafePath_AcceptRelative(t *testing.T) {
|
||||
for _, p := range []string{"local.txt", "./sub/dir/x", "a/b/c"} {
|
||||
if err := SafePath(p).ValidateValue(nil, "file"); err != nil {
|
||||
t.Errorf("%q should pass, got: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectAbsolute(t *testing.T) {
|
||||
err := SafePath("/etc/passwd").ValidateValue(nil, "file")
|
||||
if err == nil {
|
||||
t.Fatal("absolute path must be rejected")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "file" {
|
||||
t.Errorf("wrong wrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectDotDot(t *testing.T) {
|
||||
err := SafePath("../leak").ValidateValue(nil, "file")
|
||||
if err == nil {
|
||||
t.Fatal("'..' segment must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectMidPathDotDot(t *testing.T) {
|
||||
// filepath.Clean collapses "a/../b" to "b"; the raw-segment scan must
|
||||
// still reject it because the user literally typed a parent segment.
|
||||
for _, p := range []string{"a/../b", "sub/../../etc", `win\..\x`} {
|
||||
if err := SafePath(p).ValidateValue(nil, "file"); err == nil {
|
||||
t.Errorf("%q should be rejected (raw .. segment)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SpreadsheetRef is a Lark Sheets reference: either a raw "shtcn..." token
|
||||
// or a feishu.cn/larksuite.com URL containing one. Normalize extracts the
|
||||
// token from URLs; ValidateValue checks the final token shape. URL→token is
|
||||
// a business-canonicalisation hint and is safe to emit to stderr.
|
||||
type SpreadsheetRef string
|
||||
|
||||
func (s SpreadsheetRef) Normalize(_ context.Context, raw string) (SpreadsheetRef, []string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
if !strings.Contains(trimmed, "://") {
|
||||
return SpreadsheetRef(trimmed), nil, nil
|
||||
}
|
||||
for _, seg := range strings.Split(trimmed, "/") {
|
||||
if strings.HasPrefix(seg, "shtcn") {
|
||||
// Strip any ?query or #fragment suffix so a URL like
|
||||
// .../shtcnXXX?sheet=0#row=5 yields a clean token, not one
|
||||
// polluted by trailing parameters that would later pass the
|
||||
// prefix-only ValidateValue check.
|
||||
token := seg
|
||||
if i := strings.IndexAny(token, "?#"); i >= 0 {
|
||||
token = token[:i]
|
||||
}
|
||||
return SpreadsheetRef(token), []string{"extracted spreadsheet token from URL"}, nil
|
||||
}
|
||||
}
|
||||
return "", nil, &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "URL does not contain a recognisable spreadsheet token",
|
||||
},
|
||||
Param: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (s SpreadsheetRef) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
v := strings.TrimSpace(string(s))
|
||||
if v == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "spreadsheet reference is required",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(v, "shtcn") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "spreadsheet token must start with 'shtcn'",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadsheetRef_NormalizeFromURL(t *testing.T) {
|
||||
url := "https://feishu.cn/sheets/shtcnAbCdEf01234567"
|
||||
v, hints, err := SpreadsheetRef(url).Normalize(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize error: %v", err)
|
||||
}
|
||||
if string(v) != "shtcnAbCdEf01234567" {
|
||||
t.Errorf("expected extracted token, got %q", v)
|
||||
}
|
||||
if len(hints) == 0 {
|
||||
t.Errorf("expected at least one hint about URL extraction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_NormalizeStripsQueryAndFragment(t *testing.T) {
|
||||
for _, url := range []string{
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0",
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567#row=5",
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0#row=5",
|
||||
} {
|
||||
v, _, err := SpreadsheetRef(url).Normalize(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%q) error: %v", url, err)
|
||||
}
|
||||
if string(v) != "shtcnAbCdEf01234567" {
|
||||
t.Errorf("Normalize(%q): expected clean token, got %q", url, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_NormalizePassThroughToken(t *testing.T) {
|
||||
v, hints, err := SpreadsheetRef("shtcnXyz").Normalize(context.Background(), "shtcnXyz")
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize error: %v", err)
|
||||
}
|
||||
if string(v) != "shtcnXyz" {
|
||||
t.Errorf("raw token should pass through, got %q", v)
|
||||
}
|
||||
if len(hints) != 0 {
|
||||
t.Errorf("no hint expected for raw token, got %v", hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_ValidateValueRejectsEmpty(t *testing.T) {
|
||||
if err := SpreadsheetRef("").ValidateValue(nil, "spreadsheet"); err == nil {
|
||||
t.Fatal("empty value should fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_ValidateValueAcceptsToken(t *testing.T) {
|
||||
if err := SpreadsheetRef("shtcnAbCd").ValidateValue(nil, "spreadsheet"); err != nil {
|
||||
t.Errorf("token should pass: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// UserOpenID is a typed Lark user identifier with the "ou_" prefix.
|
||||
type UserOpenID string
|
||||
|
||||
func (id UserOpenID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := strings.TrimSpace(string(id))
|
||||
if s == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "user open_id is required",
|
||||
Hint: "pass --user-id ou_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, "ou_") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "invalid user open_id format: expected ou_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestUserOpenID_ValidatePass(t *testing.T) {
|
||||
if err := UserOpenID("ou_abc").ValidateValue(nil, "user-id"); err != nil {
|
||||
t.Errorf("ou_ prefix should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserOpenID_ValidateReject(t *testing.T) {
|
||||
for _, v := range []string{"", "oc_abc", "abc"} {
|
||||
err := UserOpenID(v).ValidateValue(nil, "user-id")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %q", v)
|
||||
continue
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "user-id" {
|
||||
t.Errorf("wrong wrap for %q: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,795 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// fieldSpec is the binder's intermediate representation of one Args field.
|
||||
// It captures both reflection metadata and the parsed tag values so later
|
||||
// binder stages don't repeat the parsing work.
|
||||
type fieldSpec struct {
|
||||
GoFieldName string
|
||||
FlagName string
|
||||
Description string
|
||||
DefaultValue string
|
||||
EnumValues []string
|
||||
Input []string
|
||||
Required bool
|
||||
Hidden bool
|
||||
NoSplit bool
|
||||
IsOneOfBkt bool
|
||||
IsGroup bool
|
||||
IsPtr bool
|
||||
FieldType reflect.Type
|
||||
StructType reflect.Type
|
||||
}
|
||||
|
||||
// walkArgs reflects an Args struct (must be *T where T is struct) and
|
||||
// returns one fieldSpec per top-level field. Duplicate flag tags inside
|
||||
// the same Args struct panic at Mount time — cross-shortcut duplicates are
|
||||
// not checked (cobra's own per-command Add check covers that).
|
||||
func walkArgs(t reflect.Type) ([]fieldSpec, error) {
|
||||
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("Args must be *struct, got %s", t)
|
||||
}
|
||||
st := t.Elem()
|
||||
specs := make([]fieldSpec, 0, st.NumField())
|
||||
seen := map[string]string{}
|
||||
for i := 0; i < st.NumField(); i++ {
|
||||
f := st.Field(i)
|
||||
spec, err := parseFieldSpec(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.FlagName != "" {
|
||||
if owner, dup := seen[spec.FlagName]; dup {
|
||||
panic(fmt.Sprintf("duplicate flag tag %q in Args struct: fields %s and %s",
|
||||
spec.FlagName, owner, f.Name))
|
||||
}
|
||||
seen[spec.FlagName] = f.Name
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// parseFieldSpec extracts the relevant tag values from one struct field.
|
||||
// Sub-struct (OneOf bucket / group) detection is delegated to caller stages;
|
||||
// here we only set flags about the field shape.
|
||||
func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
|
||||
spec := fieldSpec{
|
||||
GoFieldName: f.Name,
|
||||
FlagName: f.Tag.Get("flag"),
|
||||
Description: f.Tag.Get("desc"),
|
||||
DefaultValue: f.Tag.Get("default"),
|
||||
}
|
||||
if enum := f.Tag.Get("enum"); enum != "" {
|
||||
spec.EnumValues = strings.Split(enum, ",")
|
||||
}
|
||||
if _, has := f.Tag.Lookup("required"); has {
|
||||
spec.Required = true
|
||||
}
|
||||
if input := f.Tag.Get("input"); input != "" {
|
||||
for _, src := range strings.Split(input, ",") {
|
||||
switch src = strings.TrimSpace(src); src {
|
||||
case "":
|
||||
// tolerate stray commas
|
||||
case File, Stdin:
|
||||
spec.Input = append(spec.Input, src)
|
||||
default:
|
||||
return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, has := f.Tag.Lookup("hidden"); has {
|
||||
spec.Hidden = true
|
||||
}
|
||||
switch split := strings.TrimSpace(f.Tag.Get("split")); split {
|
||||
case "", "comma":
|
||||
// default: cobra StringSlice (comma-separated, also repeatable)
|
||||
case "none":
|
||||
spec.NoSplit = true // cobra StringArray: repeatable, no comma split
|
||||
default:
|
||||
return spec, fmt.Errorf("field %s: unknown split mode %q (allowed: comma, none)", f.Name, split)
|
||||
}
|
||||
ft := f.Type
|
||||
spec.FieldType = ft
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
spec.IsPtr = true
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
spec.StructType = ft
|
||||
ptr := reflect.PointerTo(ft)
|
||||
marker := reflect.TypeOf((*OneOfMarker)(nil)).Elem()
|
||||
if ft.Implements(marker) || ptr.Implements(marker) {
|
||||
spec.IsOneOfBkt = true
|
||||
} else {
|
||||
spec.IsGroup = true
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
// Leaf field validation. Multi-value flags are []string (cobra
|
||||
// StringSlice / StringArray); any other slice element type is unsupported.
|
||||
// enum / input only make sense on plain string leaves (string or a
|
||||
// string-alias like ChatID); split only on []string. Reject mismatches at
|
||||
// Mount time instead of silently skipping or panicking at runtime.
|
||||
isStringSlice := ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String
|
||||
if ft.Kind() == reflect.Slice && !isStringSlice {
|
||||
return spec, fmt.Errorf("field %s: only []string slices are supported, got %s", f.Name, ft)
|
||||
}
|
||||
if spec.NoSplit && !isStringSlice {
|
||||
return spec, fmt.Errorf("field %s: split tag is only supported on []string fields", f.Name)
|
||||
}
|
||||
if ft.Kind() != reflect.String {
|
||||
if len(spec.EnumValues) > 0 {
|
||||
return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name)
|
||||
}
|
||||
if len(spec.Input) > 0 {
|
||||
return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name)
|
||||
}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// registerFlags registers cobra flags for the given specs. Sub-struct fields
|
||||
// (OneOf bucket / group) recurse into their inner fields.
|
||||
func registerFlags(cmd *cobra.Command, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerFlags(cmd, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := registerLeaf(cmd, s, s.FieldType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerLeaf registers a single primitive flag based on the underlying type.
|
||||
func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error {
|
||||
if s.FlagName == "" {
|
||||
return nil
|
||||
}
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
def := s.DefaultValue == "true"
|
||||
cmd.Flags().Bool(s.FlagName, def, s.Description)
|
||||
case reflect.Int, reflect.Int64:
|
||||
def := 0
|
||||
if s.DefaultValue != "" {
|
||||
def, _ = strconv.Atoi(s.DefaultValue)
|
||||
}
|
||||
cmd.Flags().Int(s.FlagName, def, s.Description)
|
||||
case reflect.Slice:
|
||||
// []string (validated at parse time). NoSplit → StringArray
|
||||
// (repeatable, literal); default → StringSlice (comma-separated).
|
||||
if s.NoSplit {
|
||||
cmd.Flags().StringArray(s.FlagName, nil, s.Description)
|
||||
} else {
|
||||
cmd.Flags().StringSlice(s.FlagName, nil, s.Description)
|
||||
}
|
||||
default:
|
||||
cmd.Flags().String(s.FlagName, s.DefaultValue, s.Description)
|
||||
}
|
||||
if s.Required {
|
||||
_ = cmd.MarkFlagRequired(s.FlagName)
|
||||
}
|
||||
if s.Hidden {
|
||||
_ = cmd.Flags().MarkHidden(s.FlagName)
|
||||
}
|
||||
if len(s.EnumValues) > 0 {
|
||||
vals := s.EnumValues
|
||||
cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTypedInputs applies @file / stdin resolution to every leaf flag that
|
||||
// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups
|
||||
// (cobra flags are flat, so a nested variant's flag resolves the same way). It
|
||||
// runs before bindFlags so the file/stdin content is read back into the cobra
|
||||
// flag and then bound into the Args struct. This is the typed counterpart of
|
||||
// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty)
|
||||
// Flags slice — both ultimately call resolveInputForFlag.
|
||||
func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error {
|
||||
stdinUsed := false
|
||||
return resolveTypedInputsRec(rctx, specs, &stdinUsed)
|
||||
}
|
||||
|
||||
func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || len(s.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindFlags writes cobra-parsed values back into the Args struct. argsVal
|
||||
// is a reflect.Value of the struct (not pointer).
|
||||
func bindFlags(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
if err := bindLeaf(cmd, argsVal, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindLeaf(cmd *cobra.Command, argsVal reflect.Value, s fieldSpec) error {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.CanSet() {
|
||||
return nil
|
||||
}
|
||||
// Pointer leaves preserve "nil = not given" semantics: only allocate when
|
||||
// the user explicitly set the flag. This mirrors the OneOf bucket
|
||||
// convention (`Chat *ChatID` — nil means the variant wasn't selected) and
|
||||
// lets typed shortcuts express tri-state bool / int / string flags using
|
||||
// plain Go pointers instead of a separate Maybe[T] wrapper type.
|
||||
if s.IsPtr && !cmd.Flags().Changed(s.FlagName) {
|
||||
return nil
|
||||
}
|
||||
leafType := s.FieldType
|
||||
if leafType.Kind() == reflect.Ptr {
|
||||
leafType = leafType.Elem()
|
||||
}
|
||||
switch leafType.Kind() {
|
||||
case reflect.Bool:
|
||||
v, _ := cmd.Flags().GetBool(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(v))
|
||||
case reflect.Int, reflect.Int64:
|
||||
v, _ := cmd.Flags().GetInt(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(int64(v)).Convert(leafType))
|
||||
case reflect.Slice:
|
||||
var vals []string
|
||||
if s.NoSplit {
|
||||
vals, _ = cmd.Flags().GetStringArray(s.FlagName)
|
||||
} else {
|
||||
vals, _ = cmd.Flags().GetStringSlice(s.FlagName)
|
||||
}
|
||||
setLeaf(fv, stringsToSlice(vals, leafType))
|
||||
default:
|
||||
v, _ := cmd.Flags().GetString(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(v).Convert(leafType))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringsToSlice builds a slice value of type t (kind Slice with string-kinded
|
||||
// elements) from raw strings, element-by-element via SetString. This handles a
|
||||
// plain []string, a named slice type (type IDs []string), AND a named element
|
||||
// type (type ID string → []ID) — reflect.Convert would panic on the last case
|
||||
// because []string is not convertible to []ID.
|
||||
func stringsToSlice(vals []string, t reflect.Type) reflect.Value {
|
||||
out := reflect.MakeSlice(t, len(vals), len(vals))
|
||||
for i, v := range vals {
|
||||
out.Index(i).SetString(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setLeaf(dst reflect.Value, src reflect.Value) {
|
||||
if dst.Kind() == reflect.Ptr {
|
||||
ptr := reflect.New(dst.Type().Elem())
|
||||
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
|
||||
dst.Set(ptr)
|
||||
return
|
||||
}
|
||||
dst.Set(src.Convert(dst.Type()))
|
||||
}
|
||||
|
||||
// bindBuckets allocates and populates OneOf bucket / group sub-struct fields
|
||||
// from cobra flag state. The shared bindFlags() above only writes top-level
|
||||
// leaves; this function is the framework's recursion into nested Args structs
|
||||
// so future typed shortcuts don't each have to ship a bespoke binder helper.
|
||||
//
|
||||
// Conventions:
|
||||
// - Pointer-leaf in a bucket (e.g. *string, *argstype.ChatID): set iff
|
||||
// cobra reports the flag was explicitly provided. nil means "variant not
|
||||
// selected" — the framework's runFrameworkRules and runValidateValue
|
||||
// both honor this nil/non-nil split.
|
||||
// - Non-pointer leaf in a group (e.g. a typed-primitive field inside a
|
||||
// paired group struct): always copy the cobra flag value back. Empty
|
||||
// string is a valid "not provided" sentinel for group completeness checks.
|
||||
// - Pointer-to-group / pointer-to-bucket (a nested group/bucket pointer
|
||||
// inside an outer OneOf bucket): allocate iff at least one inner flag was
|
||||
// Changed, then recurse to bind the inner fields.
|
||||
func bindBuckets(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if !s.IsOneOfBkt {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
target := fv
|
||||
if target.Kind() == reflect.Ptr {
|
||||
if target.IsNil() {
|
||||
target.Set(reflect.New(s.StructType))
|
||||
}
|
||||
target = target.Elem()
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindBucketInner(cmd, target, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindGroups is the top-level counterpart to bindBuckets for IsGroup fields
|
||||
// (regular nested structs without an OneOf() marker). A group's inner flags
|
||||
// are registered and validated for completeness, but bindFlags above skips
|
||||
// the field; this function fills the binding gap so an Args struct can place
|
||||
// a group directly at the top level (not just nested inside an OneOf bucket).
|
||||
//
|
||||
// Conventions mirror bindBuckets / bindBucketInner:
|
||||
// - Value-type group: always populated; inner fields receive cobra flag
|
||||
// values (including defaults).
|
||||
// - Pointer-type group: allocated iff at least one inner flag was Changed,
|
||||
// so a nil group still signals "user did not engage this group at all".
|
||||
func bindGroups(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if !s.IsGroup {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
anyChanged := false
|
||||
for _, g := range inner {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
anyChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
if fv.IsNil() {
|
||||
fv.Set(reflect.New(s.StructType))
|
||||
}
|
||||
if err := bindBucketInner(cmd, fv.Elem(), inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Value-type group: populate inner fields directly.
|
||||
if err := bindBucketInner(cmd, fv, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
grandSpecs, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anyChanged := false
|
||||
for _, g := range grandSpecs {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
anyChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
target := fv
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if fv.IsNil() {
|
||||
fv.Set(reflect.New(s.StructType))
|
||||
}
|
||||
target = fv.Elem()
|
||||
}
|
||||
if err := bindBucketInner(cmd, target, grandSpecs); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
if s.IsPtr {
|
||||
if !cmd.Flags().Changed(s.FlagName) {
|
||||
continue
|
||||
}
|
||||
elemType := fv.Type().Elem()
|
||||
ptr := reflect.New(elemType)
|
||||
ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType, s.NoSplit))
|
||||
fv.Set(ptr)
|
||||
continue
|
||||
}
|
||||
fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type(), s.NoSplit))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketLeafValue reads a single cobra flag and returns it as a reflect.Value
|
||||
// convertible to targetType. It dispatches on the underlying kind so nested
|
||||
// bucket/group leaves typed as bool or int bind correctly instead of being
|
||||
// force-read through GetString (which would panic on reflect conversion of a
|
||||
// string into a numeric/bool type).
|
||||
func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type, noSplit bool) reflect.Value {
|
||||
kind := targetType.Kind()
|
||||
if kind == reflect.Ptr {
|
||||
kind = targetType.Elem().Kind()
|
||||
}
|
||||
switch kind {
|
||||
case reflect.Bool:
|
||||
v, _ := cmd.Flags().GetBool(flagName)
|
||||
return reflect.ValueOf(v).Convert(targetType)
|
||||
case reflect.Int, reflect.Int64:
|
||||
v, _ := cmd.Flags().GetInt(flagName)
|
||||
return reflect.ValueOf(int64(v)).Convert(targetType)
|
||||
case reflect.Slice:
|
||||
var vals []string
|
||||
if noSplit {
|
||||
vals, _ = cmd.Flags().GetStringArray(flagName)
|
||||
} else {
|
||||
vals, _ = cmd.Flags().GetStringSlice(flagName)
|
||||
}
|
||||
return stringsToSlice(vals, targetType)
|
||||
default:
|
||||
v, _ := cmd.Flags().GetString(flagName)
|
||||
return reflect.ValueOf(v).Convert(targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// runNormalize invokes the Normalize method (via reflection) on every field
|
||||
// whose type implements Normalizable[T]. The canonical value is written back.
|
||||
// Plan's static type assertion can't work because Normalizable is generic —
|
||||
// the method's return type is the typed T, not any — so we dispatch by
|
||||
// reflection on method shape.
|
||||
//
|
||||
// Local-path normalization hints are dropped at the primitive layer; only
|
||||
// non-path hints reach stderr (log safety).
|
||||
func runNormalize(ctx context.Context, rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
m := fv.MethodByName("Normalize")
|
||||
if !m.IsValid() {
|
||||
continue
|
||||
}
|
||||
mt := m.Type()
|
||||
if mt.NumIn() != 2 || mt.NumOut() != 3 {
|
||||
continue
|
||||
}
|
||||
if !ctxType.AssignableTo(mt.In(0)) || mt.In(1).Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
raw := asString(fv)
|
||||
rets := m.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(raw)})
|
||||
if errRet := rets[2]; !errRet.IsNil() {
|
||||
return errRet.Interface().(error)
|
||||
}
|
||||
canon := rets[0]
|
||||
if fv.CanSet() && canon.Type().AssignableTo(fv.Type()) {
|
||||
fv.Set(canon)
|
||||
}
|
||||
hints, _ := rets[1].Interface().([]string)
|
||||
if len(hints) > 0 && rt != nil && rt.Cmd != nil {
|
||||
for _, h := range hints {
|
||||
_, _ = rt.Cmd.ErrOrStderr().Write([]byte(h + "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asString(fv reflect.Value) string {
|
||||
if fv.Kind() == reflect.String {
|
||||
return fv.String()
|
||||
}
|
||||
if fv.Kind() == reflect.Ptr && !fv.IsNil() {
|
||||
return asString(fv.Elem())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runValidateValue calls ValidateValue on every Validatable field, recursing
|
||||
// into OneOf bucket / group sub-structs so typed-primitive leaves inside
|
||||
// nested Args structs (e.g. a typed ID primitive inside a OneOf bucket) still
|
||||
// get their format check. Returns the first error to keep error envelopes
|
||||
// deterministic.
|
||||
func runValidateValue(rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
structVal := fv
|
||||
if structVal.Kind() == reflect.Ptr {
|
||||
if structVal.IsNil() {
|
||||
continue
|
||||
}
|
||||
structVal = structVal.Elem()
|
||||
}
|
||||
// Some buckets/groups implement Validatable themselves (e.g. a
|
||||
// raw-JSON variant that checks JSON validity in its ValidateValue).
|
||||
// Call the struct-level check BEFORE recursing into inner fields so
|
||||
// the cross-field rule fires even when none of the inner leaves are
|
||||
// individually Validatable.
|
||||
if fv.CanInterface() {
|
||||
if val, ok := fv.Interface().(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runValidateValue(rt, structVal, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Leaf field. Skip nil pointers (variant not selected).
|
||||
if fv.Kind() == reflect.Ptr && fv.IsNil() {
|
||||
continue
|
||||
}
|
||||
if !fv.CanInterface() {
|
||||
continue
|
||||
}
|
||||
v := fv.Interface()
|
||||
if val, ok := v.(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Pointer leaf: dereference and re-check (for value-receiver
|
||||
// ValidateValue methods on the pointee type).
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if val, ok := fv.Elem().Interface().(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runFrameworkRules enforces OneOf / group / required / enum invariants and
|
||||
// returns a *errs.ValidationError on the first violation. Each rule's
|
||||
// stderr-facing param is the Args field name (not the inner struct type name),
|
||||
// so OneOf bucket errors mention the user-visible field (e.g. "Target") rather
|
||||
// than the implementation-detail type name behind it.
|
||||
//
|
||||
// Recurses into OneOf bucket sub-structs so a nested group inside a bucket
|
||||
// still gets its checkGroup fire automatically.
|
||||
func runFrameworkRules(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
switch {
|
||||
case s.IsOneOfBkt:
|
||||
if err := checkOneOf(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
structVal := fv
|
||||
if structVal.Kind() == reflect.Ptr {
|
||||
if structVal.IsNil() {
|
||||
continue
|
||||
}
|
||||
structVal = structVal.Elem()
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runFrameworkRules(cmd, structVal, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
case s.IsGroup:
|
||||
if err := checkGroup(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if err := checkEnumAndRequired(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkOneOf counts how many variants the user attempted inside the OneOf
|
||||
// bucket; exactly one must be attempted. A variant counts as "attempted" if:
|
||||
// - it's a simple pointer leaf (e.g. *string, *ChatID) and its own flag was
|
||||
// explicitly provided, or
|
||||
// - it's a nested group / bucket and ANY of its inner flags was explicitly
|
||||
// provided. No "trigger" field is required — supplying any flag of a
|
||||
// group is enough to mark the variant as attempted, and a follow-up
|
||||
// checkGroup catches the partial-fill case with shortcut_group_incomplete.
|
||||
func checkOneOf(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var triggered []string
|
||||
for _, child := range inner {
|
||||
// Simple pointer leaf variant: its own flag is the signal.
|
||||
if child.IsPtr && !child.IsOneOfBkt && !child.IsGroup {
|
||||
if child.FlagName != "" && cmd.Flags().Changed(child.FlagName) {
|
||||
triggered = append(triggered, "--"+child.FlagName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Nested group / bucket variant: any inner flag Changed counts.
|
||||
if child.IsGroup || child.IsOneOfBkt {
|
||||
grand, _ := walkArgs(reflect.PointerTo(child.StructType))
|
||||
for _, g := range grand {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
triggered = append(triggered, "--"+g.FlagName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(triggered) {
|
||||
case 0:
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutOneOfMissing,
|
||||
Message: "exactly one " + s.GoFieldName + " variant must be provided",
|
||||
},
|
||||
Param: s.GoFieldName,
|
||||
}
|
||||
case 1:
|
||||
return nil
|
||||
default:
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutOneOfMultiple,
|
||||
Message: "choose only one of " + strings.Join(triggered, ", "),
|
||||
},
|
||||
Param: s.GoFieldName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkGroup ensures all fields of a group sub-struct were provided when
|
||||
// the group's trigger (or first field) was set.
|
||||
func checkGroup(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anySet := false
|
||||
var missing []string
|
||||
for _, child := range inner {
|
||||
if child.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Changed(child.FlagName) {
|
||||
anySet = true
|
||||
continue
|
||||
}
|
||||
// Flags with a default value are never "missing" — the default is a
|
||||
// valid implicit value (e.g. an enum flag that defaults to a value).
|
||||
// Only flags without defaults need explicit user input when the
|
||||
// group is partially populated.
|
||||
if child.DefaultValue != "" {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, "--"+child.FlagName)
|
||||
}
|
||||
if anySet && len(missing) > 0 {
|
||||
// Group errors use the inner struct TYPE name (the group struct's own
|
||||
// name), not the Args field name. This matches the spec's
|
||||
// "shortcut_group_incomplete" envelope contract — callers identify
|
||||
// the *kind* of group that is incomplete, which is the type name.
|
||||
// OneOf bucket errors use the field name instead (see checkOneOf).
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutGroupIncomplete,
|
||||
Message: s.StructType.Name() + " requires " + strings.Join(missing, ", "),
|
||||
},
|
||||
Param: s.StructType.Name(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkEnumAndRequired enforces enum membership. Required-presence is already
|
||||
// enforced at cobra level via MarkFlagRequired, so this only adds the enum
|
||||
// check.
|
||||
func checkEnumAndRequired(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
if len(s.EnumValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(s.FlagName)
|
||||
if v == "" && s.DefaultValue == "" {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range s.EnumValues {
|
||||
if v == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "--" + s.FlagName + ": value must be one of " + strings.Join(s.EnumValues, "|"),
|
||||
},
|
||||
Param: s.FlagName,
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// --- top-level pointer leaf: nil = not given (mirrors OneOf bucket convention) ---
|
||||
|
||||
type ptrLeafArgs struct {
|
||||
Notify *bool `flag:"notify"`
|
||||
Limit *int `flag:"limit"`
|
||||
Name *string `flag:"name"`
|
||||
}
|
||||
|
||||
func TestBindLeaf_PtrNilWhenAbsent(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &ptrLeafArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Notify != nil || out.Limit != nil || out.Name != nil {
|
||||
t.Errorf("expected all pointer leaves nil when no flag given; got Notify=%v Limit=%v Name=%v",
|
||||
out.Notify, out.Limit, out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindLeaf_PtrSetWhenChanged covers the tri-state contract that previously
|
||||
// required Maybe[T]: a pointer leaf set to its zero value (e.g. --notify=false)
|
||||
// MUST come back non-nil so business code can tell it apart from "not given".
|
||||
func TestBindLeaf_PtrSetWhenChanged(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--notify=false", "--limit", "5", "--name", "alice"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &ptrLeafArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Notify == nil || *out.Notify != false {
|
||||
t.Errorf("Notify = %v, want non-nil false (tri-state: zero value is still 'set')", out.Notify)
|
||||
}
|
||||
if out.Limit == nil || *out.Limit != 5 {
|
||||
t.Errorf("Limit = %v, want non-nil 5", out.Limit)
|
||||
}
|
||||
if out.Name == nil || *out.Name != "alice" {
|
||||
t.Errorf("Name = %v, want non-nil alice", out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneOf bucket binding: bindBuckets / bindBucketInner / bucketLeafValue ----
|
||||
|
||||
type bcLeafBucket struct {
|
||||
S *string `flag:"lb-s"`
|
||||
I *int `flag:"lb-i"`
|
||||
B *bool `flag:"lb-b"`
|
||||
Plain string `flag:"lb-plain"` // non-pointer leaf exercises the value branch
|
||||
}
|
||||
|
||||
func (bcLeafBucket) OneOf() {}
|
||||
|
||||
type bcValueBucketArgs struct {
|
||||
Sel bcLeafBucket
|
||||
}
|
||||
|
||||
func TestBindBuckets_ValueBucketTypedLeaves(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcValueBucketArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--lb-s", "hi", "--lb-i", "7", "--lb-b", "--lb-plain", "p"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &bcValueBucketArgs{}
|
||||
val := reflect.ValueOf(out).Elem()
|
||||
if err := bindFlags(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if err := bindBuckets(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Sel.S == nil || *out.Sel.S != "hi" {
|
||||
t.Errorf("Sel.S = %v, want hi", out.Sel.S)
|
||||
}
|
||||
if out.Sel.I == nil || *out.Sel.I != 7 {
|
||||
t.Errorf("Sel.I = %v, want 7", out.Sel.I)
|
||||
}
|
||||
if out.Sel.B == nil || *out.Sel.B != true {
|
||||
t.Errorf("Sel.B = %v, want true", out.Sel.B)
|
||||
}
|
||||
if out.Sel.Plain != "p" {
|
||||
t.Errorf("Sel.Plain = %q, want p", out.Sel.Plain)
|
||||
}
|
||||
}
|
||||
|
||||
type bcPtrBucketArgs struct {
|
||||
Sel *bcLeafBucket
|
||||
}
|
||||
|
||||
func TestBindBuckets_PointerBucketAllocates(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcPtrBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--lb-s", "x"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &bcPtrBucketArgs{}
|
||||
val := reflect.ValueOf(out).Elem()
|
||||
if err := bindBuckets(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Sel == nil {
|
||||
t.Fatal("Sel pointer was not allocated")
|
||||
}
|
||||
if out.Sel.S == nil || *out.Sel.S != "x" {
|
||||
t.Errorf("Sel.S = %v, want x", out.Sel.S)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runNormalize / asString --------------------------------------------------
|
||||
|
||||
type bcNormField string
|
||||
|
||||
func (n bcNormField) Normalize(_ context.Context, raw string) (bcNormField, []string, error) {
|
||||
if raw == "boom" {
|
||||
return "", nil, errors.New("normalize failed")
|
||||
}
|
||||
return bcNormField("c:" + raw), []string{"hint: " + raw}, nil
|
||||
}
|
||||
|
||||
type bcNormArgs struct {
|
||||
Token bcNormField `flag:"token"`
|
||||
TokenPtr *bcNormField `flag:"token-ptr"`
|
||||
}
|
||||
|
||||
func TestRunNormalize_CanonicalizesAndEmitsHints(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.SetErr(&buf)
|
||||
rt := &RuntimeContext{Cmd: cmd}
|
||||
|
||||
ptr := bcNormField("xy")
|
||||
args := &bcNormArgs{Token: "raw", TokenPtr: &ptr}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
|
||||
if err := runNormalize(context.Background(), rt, reflect.ValueOf(args).Elem(), specs); err != nil {
|
||||
t.Fatalf("runNormalize: %v", err)
|
||||
}
|
||||
if args.Token != "c:raw" {
|
||||
t.Errorf("Token = %q, want c:raw", args.Token)
|
||||
}
|
||||
if got := buf.String(); got == "" || !bytes.Contains([]byte(got), []byte("hint: raw")) {
|
||||
t.Errorf("stderr = %q, want it to contain the normalize hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
type bcBadNormArgs struct {
|
||||
Token bcNormField `flag:"token"`
|
||||
}
|
||||
|
||||
func TestRunNormalize_PropagatesError(t *testing.T) {
|
||||
args := &bcBadNormArgs{Token: "boom"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
err := runNormalize(context.Background(), nil, reflect.ValueOf(args).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Normalize")
|
||||
}
|
||||
}
|
||||
|
||||
// --- checkGroup (via runFrameworkRules) ---------------------------------------
|
||||
|
||||
type bcGroupBody struct {
|
||||
A string `flag:"g-a"`
|
||||
B string `flag:"g-b"`
|
||||
C string `flag:"g-c" default:"x"` // default means never "missing"
|
||||
}
|
||||
|
||||
type bcGroupArgs struct {
|
||||
Grp bcGroupBody
|
||||
}
|
||||
|
||||
func TestCheckGroup_IncompleteReportsMissing(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
// Only --g-a set: B is missing (no default), C has a default so it is fine.
|
||||
_ = cmd.ParseFlags([]string{"--g-a", "v"})
|
||||
out := &bcGroupArgs{}
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected group_incomplete error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
|
||||
t.Errorf("Subtype = %q, want group_incomplete", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_CompleteAndUntouched(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
|
||||
|
||||
// Complete: A and B provided.
|
||||
cmd1 := &cobra.Command{Use: "t1"}
|
||||
_ = registerFlags(cmd1, specs)
|
||||
_ = cmd1.ParseFlags([]string{"--g-a", "1", "--g-b", "2"})
|
||||
if err := runFrameworkRules(cmd1, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("complete group should pass, got %v", err)
|
||||
}
|
||||
|
||||
// Untouched: nothing set → group rule does not fire.
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
_ = registerFlags(cmd2, specs)
|
||||
_ = cmd2.ParseFlags(nil)
|
||||
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("untouched group should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- checkEnumAndRequired (via runFrameworkRules) -----------------------------
|
||||
|
||||
type bcEnumArgs struct {
|
||||
Mode string `flag:"mode" enum:"a,b,c"`
|
||||
}
|
||||
|
||||
func TestCheckEnum(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcEnumArgs{}))
|
||||
|
||||
// Invalid value.
|
||||
cmd1 := &cobra.Command{Use: "t1"}
|
||||
_ = registerFlags(cmd1, specs)
|
||||
_ = cmd1.ParseFlags([]string{"--mode", "z"})
|
||||
err := runFrameworkRules(cmd1, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid_argument error for bad enum value")
|
||||
}
|
||||
if ve := mustValidationError(t, err); ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
|
||||
}
|
||||
|
||||
// Valid value.
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
_ = registerFlags(cmd2, specs)
|
||||
_ = cmd2.ParseFlags([]string{"--mode", "b"})
|
||||
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("valid enum value should pass, got %v", err)
|
||||
}
|
||||
|
||||
// Empty (no default) → enum check skipped.
|
||||
cmd3 := &cobra.Command{Use: "t3"}
|
||||
_ = registerFlags(cmd3, specs)
|
||||
_ = cmd3.ParseFlags(nil)
|
||||
if err := runFrameworkRules(cmd3, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("empty enum value should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runValidateValue recursion into a group sub-struct -----------------------
|
||||
|
||||
type bcValGroup struct {
|
||||
ID dummyValidatable `flag:"vg-id"`
|
||||
}
|
||||
|
||||
type bcValGroupArgs struct {
|
||||
Grp bcValGroup
|
||||
}
|
||||
|
||||
func TestRunValidateValue_RecursesIntoGroup(t *testing.T) {
|
||||
args := &bcValGroupArgs{}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
rt := &RuntimeContext{}
|
||||
if err := runValidateValue(rt, reflect.ValueOf(args).Elem(), specs); err != nil {
|
||||
t.Errorf("runValidateValue into group: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// newTypedInputRuntime registers the given specs on a fresh cobra command,
|
||||
// parses argv, and returns a RuntimeContext wired with a fake stdin — the
|
||||
// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go.
|
||||
func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags(argv); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
return &RuntimeContext{
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- @file / stdin on typed shortcuts -------------------------------------
|
||||
|
||||
type fileInputArgs struct {
|
||||
Content string `flag:"content" input:"file,stdin"`
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
body := "## Title\n\nbody from a file\n"
|
||||
if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Content != body {
|
||||
t.Errorf("Content = %q, want file body %q", out.Content, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_Stdin(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Content != "piped stdin body" {
|
||||
t.Errorf("Content = %q, want stdin body", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Content != "literal text" {
|
||||
t.Errorf("Content = %q, want unchanged literal", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A OneOf variant flag that declares @file/stdin must resolve too — the binder
|
||||
// recurses into buckets because cobra flags are flat regardless of nesting.
|
||||
type nestedInputVariant struct {
|
||||
Body *string `flag:"body" input:"file,stdin"`
|
||||
Raw *string `flag:"raw"`
|
||||
}
|
||||
|
||||
func (nestedInputVariant) OneOf() {}
|
||||
|
||||
type nestedInputArgs struct {
|
||||
Content nestedInputVariant
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_NestedInOneOf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
body := "nested variant body\n"
|
||||
if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &nestedInputArgs{}
|
||||
if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Content.Body == nil {
|
||||
t.Fatal("Content.Body is nil — variant not bound")
|
||||
}
|
||||
if *out.Content.Body != body {
|
||||
t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mount-time validation: enum / input only on string leaves ------------
|
||||
|
||||
type enumOnIntArgs struct {
|
||||
Level int `flag:"level" enum:"1,2,3"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for enum on int field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enum tag is only supported on string") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type inputOnBoolArgs struct {
|
||||
Flag bool `flag:"flag" input:"file"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_InputOnNonStringErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input on bool field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input tag is only supported on string") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownInputSrcArgs struct {
|
||||
Content string `flag:"content" input:"bogus"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_UnknownInputSource(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown input source")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown input source") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A string-alias enum field (e.g. an argstype primitive) must be accepted.
|
||||
type enumOnStringArgs struct {
|
||||
Priority string `flag:"priority" enum:"low,normal,high"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_EnumOnStringOK(t *testing.T) {
|
||||
specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(specs) != 1 || len(specs[0].EnumValues) != 3 {
|
||||
t.Errorf("enum not parsed: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- enum shell completion + help candidate rendering ---------------------
|
||||
|
||||
func TestRegisterLeaf_EnumCompletion(t *testing.T) {
|
||||
prev := cmdutil.FlagCompletionsEnabled()
|
||||
cmdutil.SetFlagCompletionsEnabled(true)
|
||||
defer cmdutil.SetFlagCompletionsEnabled(prev)
|
||||
|
||||
specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
fn, ok := cmd.GetFlagCompletionFunc("priority")
|
||||
if !ok || fn == nil {
|
||||
t.Fatal("expected a completion func registered for --priority")
|
||||
}
|
||||
vals, _ := fn(cmd, nil, "")
|
||||
want := map[string]bool{"low": true, "normal": true, "high": true}
|
||||
if len(vals) != 3 {
|
||||
t.Fatalf("completion candidates = %v, want low/normal/high", vals)
|
||||
}
|
||||
for _, v := range vals {
|
||||
if !want[v] {
|
||||
t.Errorf("unexpected completion candidate %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_EnumCandidates(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(one of: low|normal|high)") {
|
||||
t.Errorf("help line missing enum candidates: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_EnumAndDefault(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) {
|
||||
t.Errorf("help line missing enum or default: %q", line)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// --- []<named string> and named slice types bind without panicking --------
|
||||
// Regression guard: reflect.Convert([]string -> []myID) panics, so the binder
|
||||
// must build the slice element-by-element via SetString instead.
|
||||
|
||||
type myID string
|
||||
|
||||
type namedElemArgs struct {
|
||||
Xs []myID `flag:"xs"`
|
||||
}
|
||||
|
||||
func TestSliceFlag_NamedElementType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&namedElemArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--xs", "a,b"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &namedElemArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Xs, []myID{"a", "b"}) {
|
||||
t.Errorf("Xs = %#v, want []myID{a b}", out.Xs)
|
||||
}
|
||||
}
|
||||
|
||||
type myIDList []string
|
||||
|
||||
type namedSliceArgs struct {
|
||||
Ids myIDList `flag:"ids"`
|
||||
}
|
||||
|
||||
func TestSliceFlag_NamedSliceType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&namedSliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--ids", "x,y,z"})
|
||||
out := &namedSliceArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Ids, myIDList{"x", "y", "z"}) {
|
||||
t.Errorf("Ids = %#v, want myIDList{x y z}", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
// --- nil Execute → not mounted (parity with legacy Shortcut) ---------------
|
||||
|
||||
type nilExecArgs struct {
|
||||
Name string `flag:"name"`
|
||||
}
|
||||
|
||||
func TestMountTyped_NilExecuteNotMounted(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*nilExecArgs]{
|
||||
Service: "x", Command: "+noexec", AuthTypes: []string{"user"}, Risk: "read",
|
||||
// Execute intentionally nil — legacy skips mounting such shortcuts.
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
if sub, _, _ := root.Find([]string{"+noexec"}); sub != nil && sub.Name() == "+noexec" {
|
||||
t.Error("nil-Execute typed shortcut must NOT be mounted (parity with legacy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTyped_WithExecuteMounted(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*nilExecArgs]{
|
||||
Service: "x", Command: "+yesexec", AuthTypes: []string{"user"}, Risk: "read",
|
||||
Execute: func(ctx context.Context, args *nilExecArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
if sub, _, _ := root.Find([]string{"+yesexec"}); sub == nil || sub.Name() != "+yesexec" {
|
||||
t.Error("typed shortcut with Execute should be mounted")
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- multi-value ([]string) flags -----------------------------------------
|
||||
|
||||
type sliceArgs struct {
|
||||
Ids []string `flag:"ids"` // default → StringSlice (comma-split)
|
||||
Tags []string `flag:"tags" split:"none"` // StringArray (repeatable, no split)
|
||||
}
|
||||
|
||||
func TestSliceFlag_StringSliceDefault(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--ids", "a,b,c"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &sliceArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Ids, []string{"a", "b", "c"}) {
|
||||
t.Errorf("Ids = %#v, want [a b c] (comma-split)", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceFlag_StringArrayNoSplit(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
// repeated; a value containing a comma must NOT be split (StringArray)
|
||||
if err := cmd.ParseFlags([]string{"--tags", "a,b", "--tags", "c"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &sliceArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if !reflect.DeepEqual(out.Tags, []string{"a,b", "c"}) {
|
||||
t.Errorf("Tags = %#v, want [\"a,b\" \"c\"] (no split, repeatable)", out.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceFlag_UnsetIsEmpty(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &sliceArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if len(out.Ids) != 0 {
|
||||
t.Errorf("Ids = %#v, want empty when unset", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
type sliceGroup struct {
|
||||
Items []string `flag:"items"`
|
||||
Note string `flag:"note"`
|
||||
}
|
||||
|
||||
type groupSliceArgs struct {
|
||||
G sliceGroup
|
||||
}
|
||||
|
||||
func TestSliceFlag_InGroup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&groupSliceArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--items", "x,y", "--note", "hi"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &groupSliceArgs{}
|
||||
if err := bindGroups(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.G.Items, []string{"x", "y"}) {
|
||||
t.Errorf("G.Items = %#v, want [x y]", out.G.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mount-time validation for slices / split -----------------------------
|
||||
|
||||
type splitOnStringArgs struct {
|
||||
S string `flag:"s" split:"none"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_SplitOnNonSliceErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&splitOnStringArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "split tag is only supported on []string") {
|
||||
t.Fatalf("expected split-on-non-slice error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type intSliceArgs struct {
|
||||
N []int `flag:"n"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_NonStringSliceErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&intSliceArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "only []string slices are supported") {
|
||||
t.Fatalf("expected []int error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownSplitArgs struct {
|
||||
S []string `flag:"s" split:"bogus"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_UnknownSplitMode(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&unknownSplitArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown split mode") {
|
||||
t.Fatalf("expected unknown split mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-flag hidden ------------------------------------------------------
|
||||
|
||||
type hiddenArgs struct {
|
||||
Visible string `flag:"visible"`
|
||||
Secret string `flag:"secret" hidden:"true"`
|
||||
}
|
||||
|
||||
func TestHiddenFlag_RegisteredButHidden(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
f := cmd.Flags().Lookup("secret")
|
||||
if f == nil {
|
||||
t.Fatal("secret flag not registered")
|
||||
}
|
||||
if !f.Hidden {
|
||||
t.Error("secret flag should be marked hidden")
|
||||
}
|
||||
// hidden does not mean disabled — it still binds.
|
||||
_ = cmd.ParseFlags([]string{"--secret", "shh", "--visible", "v"})
|
||||
out := &hiddenArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Secret != "shh" {
|
||||
t.Errorf("Secret = %q, want shh (hidden but functional)", out.Secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenFlag_SkippedInHelp(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
_ = registerFlags(cmd, specs)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
buildTypedHelp(specs, nil)(cmd, nil)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "--visible") {
|
||||
t.Errorf("help should show --visible:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--secret") {
|
||||
t.Errorf("help must NOT show hidden --secret:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- @file / stdin help hint ----------------------------------------------
|
||||
|
||||
func TestFormatLeafLine_InputHintBoth(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "content", Description: "the content", Input: []string{File, Stdin}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(supports @file, - for stdin)") {
|
||||
t.Errorf("missing input hint: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_InputHintFileOnly(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "content", Description: "c", Input: []string{File}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(supports @file)") || strings.Contains(line, "stdin") {
|
||||
t.Errorf("file-only hint wrong: %q", line)
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type simpleArgs struct {
|
||||
Name string `flag:"name" desc:"a name"`
|
||||
Count int `flag:"count" default:"3"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_Simple(t *testing.T) {
|
||||
specs, err := walkArgs(reflect.TypeOf(&simpleArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 field specs, got %d", len(specs))
|
||||
}
|
||||
if specs[0].FlagName != "name" || specs[1].FlagName != "count" {
|
||||
t.Errorf("flag names: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
type dupTagArgs struct {
|
||||
A string `flag:"x"`
|
||||
B string `flag:"x"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_DuplicateTagPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic for duplicate flag tag")
|
||||
}
|
||||
}()
|
||||
_, _ = walkArgs(reflect.TypeOf(&dupTagArgs{}))
|
||||
}
|
||||
|
||||
type bindArgs struct {
|
||||
Name string `flag:"name"`
|
||||
Count int `flag:"count" default:"7"`
|
||||
}
|
||||
|
||||
func TestRegisterAndBind_StringInt(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--name", "alice", "--count", "12"})
|
||||
out := &bindArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Name != "alice" {
|
||||
t.Errorf("Name = %q, want alice", out.Name)
|
||||
}
|
||||
if out.Count != 12 {
|
||||
t.Errorf("Count = %d, want 12", out.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_DefaultApplies(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &bindArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Count != 7 {
|
||||
t.Errorf("default not applied: Count = %d, want 7", out.Count)
|
||||
}
|
||||
}
|
||||
|
||||
type withValidatable struct {
|
||||
ID dummyValidatable `flag:"id"`
|
||||
}
|
||||
|
||||
func TestRunValidateValue_CallsValidatable(t *testing.T) {
|
||||
v := &withValidatable{}
|
||||
specs, _ := walkArgs(reflect.TypeOf(v))
|
||||
rt := &RuntimeContext{}
|
||||
if err := runValidateValue(rt, reflect.ValueOf(v).Elem(), specs); err != nil {
|
||||
t.Errorf("runValidateValue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type oneOfArgs struct {
|
||||
A *string `flag:"a"`
|
||||
B *string `flag:"b"`
|
||||
}
|
||||
|
||||
func (oneOfArgs) OneOf() {}
|
||||
|
||||
type bucketArgs struct {
|
||||
Bucket oneOfArgs
|
||||
}
|
||||
|
||||
func TestFrameworkRules_OneOfMissing(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &bucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_missing error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMissing {
|
||||
t.Errorf("Subtype = %q", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkRules_OneOfMultiple(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--a", "1", "--b", "2"})
|
||||
out := &bucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_multiple error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
|
||||
t.Errorf("Subtype = %q", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// --- top-level group binding (bindGroups) ---
|
||||
|
||||
type dateRange struct {
|
||||
From string `flag:"from"`
|
||||
To string `flag:"to"`
|
||||
}
|
||||
type groupValueArgs struct {
|
||||
Range dateRange
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelValueGroup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupValueArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--from", "2026-01-01", "--to", "2026-12-31"})
|
||||
out := &groupValueArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Range.From != "2026-01-01" || out.Range.To != "2026-12-31" {
|
||||
t.Errorf("Range = %+v", out.Range)
|
||||
}
|
||||
}
|
||||
|
||||
type defaultedGroup struct {
|
||||
Port string `flag:"port" default:"8080"`
|
||||
Host string `flag:"host" default:"localhost"`
|
||||
}
|
||||
type groupDefaultArgs struct {
|
||||
Conf defaultedGroup
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelValueGroup_AppliesDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupDefaultArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &groupDefaultArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Conf.Port != "8080" || out.Conf.Host != "localhost" {
|
||||
t.Errorf("defaults not applied: Conf = %+v", out.Conf)
|
||||
}
|
||||
}
|
||||
|
||||
type proxyConf struct {
|
||||
Host string `flag:"proxy-host"`
|
||||
Port string `flag:"proxy-port"`
|
||||
}
|
||||
type groupPtrArgs struct {
|
||||
Proxy *proxyConf
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelPtrGroup_AllocatedWhenChanged(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--proxy-host", "p.example.com"})
|
||||
out := &groupPtrArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Proxy == nil {
|
||||
t.Fatal("expected Proxy to be allocated when an inner flag was changed")
|
||||
}
|
||||
if out.Proxy.Host != "p.example.com" {
|
||||
t.Errorf("Proxy.Host = %q", out.Proxy.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelPtrGroup_NilWhenAbsent(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &groupPtrArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Proxy != nil {
|
||||
t.Errorf("expected Proxy nil when no inner flag set, got %+v", out.Proxy)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneOf with a nested group variant (no oneof_trigger; any inner flag
|
||||
// counts as attempting that variant) ---
|
||||
|
||||
type vidGroup struct {
|
||||
File string `flag:"vid-file"`
|
||||
Cover string `flag:"vid-cover"`
|
||||
}
|
||||
type contentBucket struct {
|
||||
Text *string `flag:"ct"`
|
||||
Video *vidGroup
|
||||
}
|
||||
|
||||
func (contentBucket) OneOf() {}
|
||||
|
||||
type contentBucketArgs struct {
|
||||
Bucket contentBucket
|
||||
}
|
||||
|
||||
func TestCheckOneOf_GroupCompanionAloneTriggersVariant(t *testing.T) {
|
||||
// Companion --vid-cover alone (no --vid-file) should count as attempting
|
||||
// the Video variant; OneOf check passes (1 variant attempted) and the
|
||||
// group completeness check then surfaces shortcut_group_incomplete with
|
||||
// the specific missing flag, not a misleading shortcut_oneof_missing.
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected group_incomplete error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
|
||||
t.Errorf("Subtype = %q, want shortcut_group_incomplete", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneOf_GroupVariantBothFieldsOK(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--vid-file", "v.mp4", "--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Errorf("expected OK, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneOf_SimpleAndGroupBothAttempted_Multiple(t *testing.T) {
|
||||
// Text variant set AND Video group's companion set → both variants are
|
||||
// attempted; expect shortcut_oneof_multiple.
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--ct", "hi", "--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_multiple error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
|
||||
t.Errorf("Subtype = %q, want shortcut_oneof_multiple", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func mustValidationError(t *testing.T, err error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ShortcutDescriptor exposes the read-only metadata that auth, scope-hint,
|
||||
// shortcuts.json generation, and diagnose-scope consumers need. Both legacy
|
||||
// Shortcut and the new TypedShortcut[T] satisfy it (see types.go and
|
||||
// typed_shortcut.go for the implementations).
|
||||
type ShortcutDescriptor interface {
|
||||
GetService() string
|
||||
GetCommand() string
|
||||
GetDescription() string
|
||||
GetAuthTypes() []string
|
||||
GetRisk() string
|
||||
ScopesForIdentity(identity string) []string
|
||||
ConditionalScopesForIdentity(identity string) []string
|
||||
DeclaredScopesForIdentity(identity string) []string
|
||||
}
|
||||
|
||||
// Mountable is the registration contract for register.go. ShortcutDescriptor
|
||||
// is embedded so a single interface slice covers both metadata reads and
|
||||
// cobra mounting.
|
||||
type Mountable interface {
|
||||
ShortcutDescriptor
|
||||
MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory)
|
||||
}
|
||||
|
||||
// OneOfMarker is the opt-in marker for "exactly one variant" sub-structs.
|
||||
// Variant fields must be pointers; the binder treats a non-nil pointer as
|
||||
// "this variant was selected by user-set trigger flag". See spec §"OneOf
|
||||
// trigger semantics" for the trigger rule.
|
||||
type OneOfMarker interface {
|
||||
OneOf()
|
||||
}
|
||||
|
||||
// Validatable is implemented by typed primitives (and may be by sub-structs
|
||||
// that validate a composite value, e.g. a raw JSON body) that own their
|
||||
// format check. The binder calls
|
||||
// ValidateValue per field after Normalize. Returning an error must produce
|
||||
// a *errs.ValidationError so the stderr envelope carries type/subtype/param.
|
||||
type Validatable interface {
|
||||
ValidateValue(rt *RuntimeContext, flagName string) error
|
||||
}
|
||||
|
||||
// Normalizable[T] is implemented by typed primitives that canonicalize raw
|
||||
// user input (e.g. SpreadsheetRef extracting token from URL). The binder
|
||||
// calls Normalize before ValidateValue and writes the canonical value back.
|
||||
// hints, if any, are written to stderr once during the Validate phase.
|
||||
//
|
||||
// Local-path Normalize MUST NOT emit absolute paths in hints (log safety).
|
||||
type Normalizable[T any] interface {
|
||||
Normalize(ctx context.Context, raw string) (value T, hints []string, err error)
|
||||
}
|
||||
|
||||
// ArgsValidator is the cross-field escape hatch. An Args struct may opt in
|
||||
// by adding this method; the binder calls it after framework-derived
|
||||
// validation (required / enum / OneOf / group), before the user-defined
|
||||
// TypedShortcut.Validate hook.
|
||||
type ArgsValidator interface {
|
||||
Validate(ctx context.Context, rt *RuntimeContext) error
|
||||
}
|
||||
|
||||
// HelpExample appears in TypedShortcut.Examples and is rendered by
|
||||
// typed_help under an "EXAMPLES:" section.
|
||||
type HelpExample struct {
|
||||
Title string
|
||||
Cmd string
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dummyOneOf demonstrates how a sub-struct opts into OneOfMarker by adding
|
||||
// the empty OneOf() method.
|
||||
type dummyOneOf struct{ A, B *string }
|
||||
|
||||
func (dummyOneOf) OneOf() {}
|
||||
|
||||
func TestOneOfMarker_Detection(t *testing.T) {
|
||||
var v interface{} = dummyOneOf{}
|
||||
if _, ok := v.(OneOfMarker); !ok {
|
||||
t.Fatal("dummyOneOf should satisfy OneOfMarker")
|
||||
}
|
||||
}
|
||||
|
||||
// dummyValidatable implements Validatable.
|
||||
type dummyValidatable struct{}
|
||||
|
||||
func (dummyValidatable) ValidateValue(rt *RuntimeContext, flag string) error { return nil }
|
||||
|
||||
func TestValidatable_InterfaceShape(t *testing.T) {
|
||||
var _ Validatable = dummyValidatable{}
|
||||
}
|
||||
|
||||
// dummyNormalizable implements Normalizable[string].
|
||||
type dummyNormalizable struct{}
|
||||
|
||||
func (dummyNormalizable) Normalize(ctx context.Context, raw string) (string, []string, error) {
|
||||
return raw, nil, nil
|
||||
}
|
||||
|
||||
func TestNormalizable_InterfaceShape(t *testing.T) {
|
||||
var n Normalizable[string] = dummyNormalizable{}
|
||||
got, hints, err := n.Normalize(context.Background(), "x")
|
||||
if err != nil || got != "x" || hints != nil {
|
||||
t.Errorf("dummy Normalize round-trip: got=%q hints=%v err=%v", got, hints, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpExample_Fields(t *testing.T) {
|
||||
ex := HelpExample{Title: "send text", Cmd: "--chat-id oc_x --text hi"}
|
||||
if ex.Title != "send text" || ex.Cmd != "--chat-id oc_x --text hi" {
|
||||
t.Errorf("HelpExample: got %+v", ex)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ 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"
|
||||
@@ -47,7 +46,6 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
typedArgs any // per-run typed Args, set by binder; consumed by DryRun/Execute
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -235,133 +233,6 @@ 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.
|
||||
//
|
||||
@@ -681,47 +552,28 @@ 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, true)
|
||||
ctx.emit(data, meta, false)
|
||||
}
|
||||
|
||||
// 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, true)
|
||||
ctx.emit(data, meta, true)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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, ok bool) {
|
||||
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw 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: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
@@ -1018,69 +870,56 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil {
|
||||
return err
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single
|
||||
// string flag. sources lists the accepted inputs (File / Stdin); empty sources
|
||||
// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags
|
||||
// of one invocation so stdin (-) is consumed by at most one flag. Both the
|
||||
// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call
|
||||
// through here, so @file / stdin behaves identically on TypedShortcut[T].
|
||||
func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", name)
|
||||
}
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(sources, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", name)
|
||||
}
|
||||
if *stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", name)
|
||||
}
|
||||
*stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", name, err)
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
rctx.Cmd.Flags().Set(name, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// escape: @@ → literal @
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @
|
||||
return nil
|
||||
}
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(fl.Input, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
}
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(sources, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", name)
|
||||
// escape: @@ → literal @
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", name)
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(fl.Input, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(name, string(data))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1197,12 +1036,3 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
// TypedArgs returns the per-run Args struct populated by the binder. Returns
|
||||
// nil for runs that didn't go through TypedShortcut[T]. Callers should
|
||||
// type-assert to the concrete *Args type.
|
||||
func (ctx *RuntimeContext) TypedArgs() any { return ctx.typedArgs }
|
||||
|
||||
// SetTypedArgs stores the per-run Args struct. Called once by the binder
|
||||
// after bind + Normalize complete. Must not be called by user hooks.
|
||||
func (ctx *RuntimeContext) SetTypedArgs(v any) { ctx.typedArgs = v }
|
||||
|
||||
@@ -56,17 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_TypedArgs_RoundTrip(t *testing.T) {
|
||||
type sample struct{ X int }
|
||||
rt := &RuntimeContext{}
|
||||
if got := rt.TypedArgs(); got != nil {
|
||||
t.Fatalf("fresh RuntimeContext.TypedArgs() = %v, want nil", got)
|
||||
}
|
||||
in := &sample{X: 42}
|
||||
rt.SetTypedArgs(in)
|
||||
out, ok := rt.TypedArgs().(*sample)
|
||||
if !ok || out != in {
|
||||
t.Errorf("round-trip failed: got %v ok=%v", rt.TypedArgs(), ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// buildTypedHelp returns a cobra.HelpFunc that renders typed shortcuts in
|
||||
// sections (CHOOSE ONE <FIELD> / OPTIONAL / EXAMPLES / GLOBAL FLAGS / Risk: /
|
||||
// Tips:). Cobra's default HelpFunc is preserved for all non-typed commands;
|
||||
// we only override per-command via cmd.SetHelpFunc.
|
||||
//
|
||||
// Section titles use the Args struct's field name (e.g. "TARGET", "CONTENT"),
|
||||
// not the inner Go type name behind the field, so the help mirrors the
|
||||
// user-visible variable name rather than an implementation detail.
|
||||
func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Command, []string) {
|
||||
return func(cmd *cobra.Command, _ []string) {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%s — %s\n\n", cmd.Use, cmd.Short)
|
||||
rendered := map[string]struct{}{}
|
||||
renderOneOfSections(w, specs, rendered)
|
||||
renderRequiredSection(w, specs, rendered)
|
||||
renderOptionalSection(w, specs, rendered)
|
||||
renderExamples(w, examples)
|
||||
renderGlobalFlags(w, cmd, rendered)
|
||||
if r, ok := cmdutil.GetRisk(cmd); ok && r != "" {
|
||||
fmt.Fprintf(w, "Risk: %s\n\n", r)
|
||||
}
|
||||
for _, tip := range cmdutil.GetTips(cmd) {
|
||||
fmt.Fprintf(w, "Tips: %s\n", tip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatLeafLine renders one leaf flag line — "--name description" with an
|
||||
// optional `(default "x")` suffix when the field declares a default. Reused by
|
||||
// every section renderer so REQUIRED / OPTIONAL / CHOOSE ONE all surface the
|
||||
// same information density as cobra's legacy default help.
|
||||
func formatLeafLine(indent string, s fieldSpec) string {
|
||||
line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description)
|
||||
if len(s.EnumValues) > 0 {
|
||||
line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|"))
|
||||
}
|
||||
if len(s.Input) > 0 {
|
||||
var srcs []string
|
||||
for _, src := range s.Input {
|
||||
switch src {
|
||||
case File:
|
||||
srcs = append(srcs, "@file")
|
||||
case Stdin:
|
||||
srcs = append(srcs, "- for stdin")
|
||||
}
|
||||
}
|
||||
line += fmt.Sprintf(" (supports %s)", strings.Join(srcs, ", "))
|
||||
}
|
||||
if s.DefaultValue != "" {
|
||||
line += fmt.Sprintf(" (default %q)", s.DefaultValue)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderOneOfSections walks each OneOf bucket and prints "CHOOSE ONE <FIELD>:"
|
||||
// followed by every flag inside the bucket — including flags inside nested
|
||||
// groups (a paired group's companion flag under its trigger) and nested
|
||||
// raw-content variants (a raw-JSON variant's body + msg-type flags).
|
||||
func renderOneOfSections(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
for _, s := range specs {
|
||||
if !s.IsOneOfBkt {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "CHOOSE ONE %s:\n", strings.ToUpper(s.GoFieldName))
|
||||
inner, _ := walkArgs(reflect.PointerTo(s.StructType))
|
||||
renderFlagsInBucket(w, inner, " ", rendered)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// renderFlagsInBucket renders bucket inner flags, recursing into nested group
|
||||
// / OneOf sub-structs. The structure is FLATTENED — every leaf flag of an
|
||||
// inner variant renders at the parent's indent. The framework treats any
|
||||
// inner flag of a group variant equally (providing any of them counts as
|
||||
// selecting that variant), so the help no longer visually distinguishes a
|
||||
// "trigger" from a "companion".
|
||||
func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered map[string]struct{}) {
|
||||
for _, child := range specs {
|
||||
if child.IsGroup || child.IsOneOfBkt {
|
||||
inner, _ := walkArgs(reflect.PointerTo(child.StructType))
|
||||
for _, leaf := range inner {
|
||||
if leaf.IsGroup || leaf.IsOneOfBkt {
|
||||
grand, _ := walkArgs(reflect.PointerTo(leaf.StructType))
|
||||
renderFlagsInBucket(w, grand, indent+" ", rendered)
|
||||
continue
|
||||
}
|
||||
if leaf.FlagName == "" || leaf.Hidden {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(indent, leaf))
|
||||
rendered[leaf.FlagName] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if child.FlagName == "" || child.Hidden {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(indent, child))
|
||||
rendered[child.FlagName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// renderRequiredSection prints top-level leaf flags that carry the `required`
|
||||
// tag under a "REQUIRED:" header so users can see at a glance which flags they
|
||||
// must supply. Sub-struct fields are skipped here because they're surfaced
|
||||
// under the CHOOSE ONE sections above.
|
||||
func renderRequiredSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
anyFlag := false
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || !s.Required || s.Hidden {
|
||||
continue
|
||||
}
|
||||
if !anyFlag {
|
||||
fmt.Fprintln(w, "REQUIRED:")
|
||||
anyFlag = true
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(" ", s))
|
||||
rendered[s.FlagName] = struct{}{}
|
||||
}
|
||||
if anyFlag {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// renderOptionalSection prints top-level leaf flags that don't belong to any
|
||||
// OneOf bucket and aren't tagged required (those are handled by
|
||||
// renderRequiredSection above). Sub-struct fields are skipped here because
|
||||
// they live under CHOOSE ONE sections above.
|
||||
func renderOptionalSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
anyFlag := false
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || s.Required || s.Hidden {
|
||||
continue
|
||||
}
|
||||
if !anyFlag {
|
||||
fmt.Fprintln(w, "OPTIONAL:")
|
||||
anyFlag = true
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(" ", s))
|
||||
rendered[s.FlagName] = struct{}{}
|
||||
}
|
||||
if anyFlag {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func renderExamples(w io.Writer, examples []HelpExample) {
|
||||
if len(examples) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "EXAMPLES:")
|
||||
for _, e := range examples {
|
||||
fmt.Fprintf(w, " %-20s %s\n", e.Title+":", e.Cmd)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// renderGlobalFlags prints framework-injected and cobra-inherited flags
|
||||
// (--as / --dry-run / --jq / --format / -h / --help) that the typed Args
|
||||
// struct does NOT define. The `rendered` set captures every flag name we
|
||||
// already emitted under CHOOSE ONE / OPTIONAL — anything else surfaced by
|
||||
// cmd.Flags() or cmd.InheritedFlags() falls under GLOBAL FLAGS.
|
||||
func renderGlobalFlags(w io.Writer, cmd *cobra.Command, rendered map[string]struct{}) {
|
||||
type globalFlag struct {
|
||||
Name string
|
||||
Shorthand string
|
||||
Usage string
|
||||
}
|
||||
var globals []globalFlag
|
||||
seen := map[string]bool{}
|
||||
collect := func(fs *pflag.FlagSet) {
|
||||
fs.VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
if _, alreadyRendered := rendered[f.Name]; alreadyRendered {
|
||||
return
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
globals = append(globals, globalFlag{Name: f.Name, Shorthand: f.Shorthand, Usage: f.Usage})
|
||||
})
|
||||
}
|
||||
collect(cmd.Flags())
|
||||
collect(cmd.InheritedFlags())
|
||||
// Cobra auto-injects --help on every command, but it does not appear in
|
||||
// LocalFlags or InheritedFlags until the command has been resolved at
|
||||
// invocation time. Ensure it is always documented.
|
||||
if !seen["help"] {
|
||||
globals = append(globals, globalFlag{Name: "help", Shorthand: "h", Usage: "show help"})
|
||||
}
|
||||
if len(globals) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "GLOBAL FLAGS:")
|
||||
for _, g := range globals {
|
||||
if g.Shorthand != "" {
|
||||
fmt.Fprintf(w, " -%s, --%s %s\n", g.Shorthand, g.Name, g.Usage)
|
||||
} else {
|
||||
fmt.Fprintf(w, " --%s %s\n", g.Name, g.Usage)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
type helpDemoTarget struct {
|
||||
Chat *string `flag:"chat-id"`
|
||||
User *string `flag:"user-id"`
|
||||
}
|
||||
|
||||
func (helpDemoTarget) OneOf() {}
|
||||
|
||||
type helpDemoArgs struct {
|
||||
Target helpDemoTarget
|
||||
Idemp string `flag:"idempotency-key"`
|
||||
}
|
||||
|
||||
func TestTypedHelp_RendersSections(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
cmdutil.SetTips(cmd, []string{"call carefully"})
|
||||
specs, err := walkArgs(reflect.TypeOf(&helpDemoArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, []HelpExample{{Title: "demo", Cmd: "--chat-id oc_x"}}))
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
out := buf.String()
|
||||
for _, section := range []string{"CHOOSE ONE", "OPTIONAL", "EXAMPLES", "Risk:", "Tips:"} {
|
||||
if !strings.Contains(out, section) {
|
||||
t.Errorf("help missing %q section; got:\n%s", section, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helpReqDefaultsArgs covers two cases the renderer previously botched:
|
||||
//
|
||||
// 1. A required flag (--limit) should land under "REQUIRED:" instead of
|
||||
// being silently glued into "OPTIONAL:".
|
||||
// 2. A flag with default (--page-size) should display (default "20").
|
||||
type helpReqDefaultsArgs struct {
|
||||
Limit string `flag:"limit" required:"true" desc:"max items"`
|
||||
PageSize string `flag:"page-size" default:"20" desc:"page size"`
|
||||
Verbose string `flag:"verbose" desc:"output mode"`
|
||||
}
|
||||
|
||||
func TestTypedHelp_RequiredSectionAndDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&helpReqDefaultsArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, nil))
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "REQUIRED:") {
|
||||
t.Errorf("expected REQUIRED: section, got:\n%s", out)
|
||||
}
|
||||
// --limit must be under REQUIRED, NOT under OPTIONAL.
|
||||
reqIdx := strings.Index(out, "REQUIRED:")
|
||||
optIdx := strings.Index(out, "OPTIONAL:")
|
||||
limitIdx := strings.Index(out, "--limit")
|
||||
if reqIdx < 0 || optIdx < 0 || limitIdx < 0 {
|
||||
t.Fatalf("layout markers missing: REQUIRED@%d OPTIONAL@%d --limit@%d\n%s", reqIdx, optIdx, limitIdx, out)
|
||||
}
|
||||
if !(reqIdx < limitIdx && limitIdx < optIdx) {
|
||||
t.Errorf("--limit should appear under REQUIRED before OPTIONAL; got:\n%s", out)
|
||||
}
|
||||
|
||||
// Default value must be surfaced for --page-size.
|
||||
if !strings.Contains(out, `(default "20")`) {
|
||||
t.Errorf("expected default value rendering for --page-size; got:\n%s", out)
|
||||
}
|
||||
|
||||
// Plain flag without default or required tag must NOT carry a (default …) suffix.
|
||||
verboseLine := ""
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "--verbose") {
|
||||
verboseLine = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if verboseLine == "" {
|
||||
t.Fatalf("expected --verbose flag to be rendered; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(verboseLine, "(default") {
|
||||
t.Errorf("--verbose has no default, should not carry default suffix: %q", verboseLine)
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TypedShortcut is the generic counterpart of the legacy Shortcut struct.
|
||||
// Args must be a pointer to a struct (e.g. *MyArgs). Mounting reflects the
|
||||
// struct, registers cobra flags, then delegates to a synthesized Shortcut
|
||||
// shell so the existing run pipeline (identity / scopes / @file / stdin /
|
||||
// jq / dry-run / high-risk gate) is reused verbatim.
|
||||
type TypedShortcut[T any] struct {
|
||||
Service, Command, Description string
|
||||
Risk string
|
||||
Scopes, UserScopes, BotScopes []string
|
||||
ConditionalScopes []string
|
||||
ConditionalUserScopes []string
|
||||
ConditionalBotScopes []string
|
||||
AuthTypes []string
|
||||
HasFormat bool
|
||||
Tips []string
|
||||
Hidden bool
|
||||
|
||||
Examples []HelpExample
|
||||
|
||||
DryRun func(ctx context.Context, args T, rt *RuntimeContext) *DryRunAPI
|
||||
Validate func(ctx context.Context, args T, rt *RuntimeContext) error
|
||||
Execute func(ctx context.Context, args T, rt *RuntimeContext) error
|
||||
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) GetService() string { return s.Service }
|
||||
func (s TypedShortcut[T]) GetCommand() string { return s.Command }
|
||||
func (s TypedShortcut[T]) GetDescription() string { return s.Description }
|
||||
func (s TypedShortcut[T]) GetAuthTypes() []string { return s.AuthTypes }
|
||||
func (s TypedShortcut[T]) GetRisk() string { return s.Risk }
|
||||
|
||||
func (s TypedShortcut[T]) ScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.UserScopes) > 0 {
|
||||
return s.UserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.BotScopes) > 0 {
|
||||
return s.BotScopes
|
||||
}
|
||||
}
|
||||
return s.Scopes
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) ConditionalScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.ConditionalUserScopes) > 0 {
|
||||
return s.ConditionalUserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.ConditionalBotScopes) > 0 {
|
||||
return s.ConditionalBotScopes
|
||||
}
|
||||
}
|
||||
return s.ConditionalScopes
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) DeclaredScopesForIdentity(identity string) []string {
|
||||
base := s.ScopesForIdentity(identity)
|
||||
extra := s.ConditionalScopesForIdentity(identity)
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
seen := map[string]struct{}{}
|
||||
for _, scope := range append(base, extra...) {
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
out = append(out, scope)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Mount registers the typed shortcut on a parent command, mirroring the legacy
|
||||
// Shortcut.Mount API so migrating common.Shortcut → common.TypedShortcut[T]
|
||||
// does not force existing callers (or their tests) to also rewrite the Mount
|
||||
// call site. Delegates to MountWithContext with a background context.
|
||||
func (s TypedShortcut[T]) Mount(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
s.MountWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
// MountWithContext is implemented in Task 16's adapter section.
|
||||
func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
mountTyped[T](ctx, parent, f, s)
|
||||
}
|
||||
|
||||
// mountTyped synthesizes a legacy *Shortcut shell that delegates back to the
|
||||
// typed hooks. The shell's Validate/DryRun/Execute closures read or write
|
||||
// the per-run typed args via RuntimeContext.{TypedArgs,SetTypedArgs}.
|
||||
//
|
||||
// Pipeline order inside the shell (matches runShortcut at runner.go:748):
|
||||
//
|
||||
// 1. identity / scopes / runtime — handled by Shortcut.runShortcut
|
||||
// 2. validateEnumFlags — Shortcut machinery
|
||||
// 3. resolveInputFlags — @file / stdin (legacy shell only; the
|
||||
// synthesized shell's Flags slice is empty, so this is a no-op for typed —
|
||||
// typed flags declare inputs via the `input` tag, resolved in step 5)
|
||||
// 4. ValidateJqFlags — --jq
|
||||
// 5. shell.Validate — resolveTypedInputs (@file / stdin for
|
||||
// `input`-tagged flags), binds T, runs Normalize / ValidateValue /
|
||||
// framework rules / ArgsValidator / user-typed Validate
|
||||
// 6. --dry-run gate — shell.DryRun reads typed args from rt
|
||||
// 7. high-risk-write confirmation — when Risk == "high-risk-write"
|
||||
// 8. shell.Execute — reads typed args from rt
|
||||
func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, s TypedShortcut[T]) {
|
||||
// Mirror legacy Shortcut.MountWithContext: a shortcut with no Execute is
|
||||
// not a runnable command, so it is not mounted at all (rather than mounted
|
||||
// and erroring at invocation time). Keeps the command tree identical to
|
||||
// legacy after migration.
|
||||
if s.Execute == nil {
|
||||
return
|
||||
}
|
||||
var zero T
|
||||
argsType := reflect.TypeOf(zero)
|
||||
if argsType == nil || argsType.Kind() != reflect.Ptr {
|
||||
panic("TypedShortcut[T]: T must be a pointer to a struct, got " + fmt.Sprintf("%T", zero))
|
||||
}
|
||||
specs, err := walkArgs(argsType)
|
||||
if err != nil {
|
||||
panic("TypedShortcut[T].Mount: " + err.Error())
|
||||
}
|
||||
|
||||
shell := Shortcut{
|
||||
Service: s.Service,
|
||||
Command: s.Command,
|
||||
Description: s.Description,
|
||||
Risk: s.Risk,
|
||||
Scopes: s.Scopes,
|
||||
UserScopes: s.UserScopes,
|
||||
BotScopes: s.BotScopes,
|
||||
ConditionalScopes: s.ConditionalScopes,
|
||||
ConditionalUserScopes: s.ConditionalUserScopes,
|
||||
ConditionalBotScopes: s.ConditionalBotScopes,
|
||||
AuthTypes: s.AuthTypes,
|
||||
HasFormat: s.HasFormat,
|
||||
Tips: s.Tips,
|
||||
Hidden: s.Hidden,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
panic("TypedShortcut[T] registerFlags: " + err.Error())
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, s.Examples))
|
||||
if s.PostMount != nil {
|
||||
s.PostMount(cmd)
|
||||
}
|
||||
},
|
||||
Validate: func(c context.Context, rt *RuntimeContext) error {
|
||||
// @file / stdin resolution for flags that declared an `input` tag.
|
||||
// Runs before bindFlags so the resolved file/stdin content is what
|
||||
// gets bound into the Args struct.
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
args := reflect.New(argsType.Elem()).Interface().(T)
|
||||
argsVal := reflect.ValueOf(args).Elem()
|
||||
if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindBuckets(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindGroups(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runNormalize(c, rt, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runValidateValue(rt, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runFrameworkRules(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if av, ok := any(args).(ArgsValidator); ok {
|
||||
if err := av.Validate(c, rt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rt.SetTypedArgs(args)
|
||||
if s.Validate != nil {
|
||||
return s.Validate(c, args, rt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(c context.Context, rt *RuntimeContext) error {
|
||||
if s.Execute == nil {
|
||||
return &errs.InternalError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Message: "shortcut " + s.Service + " " + s.Command + " has no Execute handler",
|
||||
},
|
||||
}
|
||||
}
|
||||
args, _ := rt.TypedArgs().(T)
|
||||
return s.Execute(c, args, rt)
|
||||
},
|
||||
}
|
||||
if s.DryRun != nil {
|
||||
shell.DryRun = func(c context.Context, rt *RuntimeContext) *DryRunAPI {
|
||||
args, _ := rt.TypedArgs().(T)
|
||||
return s.DryRun(c, args, rt)
|
||||
}
|
||||
}
|
||||
shell.MountWithContext(ctx, parent, f)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
type stubArgs struct{}
|
||||
|
||||
func newTypedFixture() TypedShortcut[*stubArgs] {
|
||||
return TypedShortcut[*stubArgs]{
|
||||
Service: "im",
|
||||
Command: "+demo",
|
||||
Description: "demo",
|
||||
AuthTypes: []string{"user"},
|
||||
Risk: "write",
|
||||
Scopes: []string{"x"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_Descriptors(t *testing.T) {
|
||||
ts := newTypedFixture()
|
||||
if ts.GetService() != "im" {
|
||||
t.Errorf("GetService=%q", ts.GetService())
|
||||
}
|
||||
if ts.GetCommand() != "+demo" {
|
||||
t.Errorf("GetCommand=%q", ts.GetCommand())
|
||||
}
|
||||
if ts.GetDescription() != "demo" {
|
||||
t.Errorf("GetDescription=%q", ts.GetDescription())
|
||||
}
|
||||
if ts.GetRisk() != "write" {
|
||||
t.Errorf("GetRisk=%q", ts.GetRisk())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_SatisfiesMountable(t *testing.T) {
|
||||
var _ Mountable = newTypedFixture()
|
||||
}
|
||||
|
||||
type adapterArgs struct {
|
||||
Name string `flag:"name"`
|
||||
}
|
||||
|
||||
// TestMountTyped_RegistersFlags verifies the mountTyped adapter wires the
|
||||
// binder-registered flag into cobra. Full Validate/Execute integration is
|
||||
// covered by tests_e2e/shortcuts/ (out of unit-test scope — runShortcut
|
||||
// needs a fully-initialized Factory with auth/config).
|
||||
func TestMountTyped_RegistersFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
sub, _, err := root.Find([]string{"+demo"})
|
||||
if err != nil {
|
||||
t.Fatalf("find subcommand: %v", err)
|
||||
}
|
||||
if sub.Flag("name") == nil {
|
||||
t.Error("expected --name flag to be registered via binder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTyped_HelpFuncInstalled(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Examples: []HelpExample{{Title: "demo", Cmd: "--name alice"}},
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
sub, _, _ := root.Find([]string{"+demo"})
|
||||
if sub == nil || sub.HelpFunc() == nil {
|
||||
t.Fatal("expected typed help func installed on subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedShortcut_Mount verifies the no-context convenience Mount API still
|
||||
// works after migration, mirroring legacy Shortcut.Mount so existing tests of
|
||||
// migrated shortcuts don't need to switch to MountWithContext.
|
||||
func TestTypedShortcut_Mount(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.Mount(root, &cmdutil.Factory{})
|
||||
sub, _, err := root.Find([]string{"+demo"})
|
||||
if err != nil || sub == nil {
|
||||
t.Fatalf("find subcommand: %v (sub=%v)", err, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_GetAuthTypes(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{AuthTypes: []string{"user", "bot"}}
|
||||
if got := ts.GetAuthTypes(); !slices.Equal(got, []string{"user", "bot"}) {
|
||||
t.Errorf("GetAuthTypes=%v", got)
|
||||
}
|
||||
if got := (TypedShortcut[*stubArgs]{}).GetAuthTypes(); got != nil {
|
||||
t.Errorf("empty GetAuthTypes=%v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_ScopesForIdentity(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
Scopes: []string{"base"},
|
||||
UserScopes: []string{"u"},
|
||||
BotScopes: []string{"b"},
|
||||
}
|
||||
cases := []struct {
|
||||
identity string
|
||||
want []string
|
||||
}{
|
||||
{"user", []string{"u"}},
|
||||
{"bot", []string{"b"}},
|
||||
{"other", []string{"base"}},
|
||||
{"", []string{"base"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ts.ScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
|
||||
t.Errorf("ScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
|
||||
}
|
||||
}
|
||||
// Falls back to Scopes when the identity-specific list is empty.
|
||||
fallback := TypedShortcut[*stubArgs]{Scopes: []string{"base"}}
|
||||
if got := fallback.ScopesForIdentity("user"); !slices.Equal(got, []string{"base"}) {
|
||||
t.Errorf("fallback user=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_ConditionalScopesForIdentity(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
ConditionalScopes: []string{"cbase"},
|
||||
ConditionalUserScopes: []string{"cu"},
|
||||
ConditionalBotScopes: []string{"cb"},
|
||||
}
|
||||
cases := []struct {
|
||||
identity string
|
||||
want []string
|
||||
}{
|
||||
{"user", []string{"cu"}},
|
||||
{"bot", []string{"cb"}},
|
||||
{"other", []string{"cbase"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ts.ConditionalScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
|
||||
t.Errorf("ConditionalScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_DeclaredScopesForIdentity(t *testing.T) {
|
||||
// Merges base + conditional, dedupes overlap, drops empty strings.
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
UserScopes: []string{"a", "b", ""},
|
||||
ConditionalUserScopes: []string{"b", "c"},
|
||||
}
|
||||
if got := ts.DeclaredScopesForIdentity("user"); !slices.Equal(got, []string{"a", "b", "c"}) {
|
||||
t.Errorf("merge+dedupe got %v", got)
|
||||
}
|
||||
// Returns nil when nothing is declared on either side.
|
||||
if got := (TypedShortcut[*stubArgs]{}).DeclaredScopesForIdentity("user"); got != nil {
|
||||
t.Errorf("empty got %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -125,23 +125,3 @@ func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetService returns the parent cobra command name (e.g. "im"). Trivial
|
||||
// accessor so *Shortcut satisfies ShortcutDescriptor alongside the existing
|
||||
// pointer-receiver scope methods.
|
||||
func (s *Shortcut) GetService() string { return s.Service }
|
||||
|
||||
// GetCommand returns the shortcut subcommand name (e.g. "+messages-send").
|
||||
func (s *Shortcut) GetCommand() string { return s.Command }
|
||||
|
||||
// GetDescription returns the short help text.
|
||||
func (s *Shortcut) GetDescription() string { return s.Description }
|
||||
|
||||
// GetAuthTypes returns the supported identities. Defaults to ["user"] is
|
||||
// applied at mount time, not here, to preserve the existing field semantics.
|
||||
func (s *Shortcut) GetAuthTypes() []string { return s.AuthTypes }
|
||||
|
||||
// GetRisk returns the static risk level ("read" / "write" / "high-risk-write").
|
||||
// Empty string defaults to "read" by convention; callers must handle that
|
||||
// case (same convention as the existing cobra annotation logic).
|
||||
func (s *Shortcut) GetRisk() string { return s.Risk }
|
||||
|
||||
@@ -105,32 +105,3 @@ func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
|
||||
t.Errorf("expected conditional-only declared scopes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcut_DescriptorAccessors(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-send",
|
||||
Description: "Send a message",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Risk: "write",
|
||||
}
|
||||
if s.GetService() != "im" {
|
||||
t.Errorf("GetService = %q", s.GetService())
|
||||
}
|
||||
if s.GetCommand() != "+messages-send" {
|
||||
t.Errorf("GetCommand = %q", s.GetCommand())
|
||||
}
|
||||
if s.GetDescription() != "Send a message" {
|
||||
t.Errorf("GetDescription = %q", s.GetDescription())
|
||||
}
|
||||
if got := s.GetAuthTypes(); len(got) != 2 || got[0] != "user" || got[1] != "bot" {
|
||||
t.Errorf("GetAuthTypes = %v", got)
|
||||
}
|
||||
if s.GetRisk() != "write" {
|
||||
t.Errorf("GetRisk = %q", s.GetRisk())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcut_SatisfiesShortcutDescriptor(t *testing.T) {
|
||||
var _ ShortcutDescriptor = &Shortcut{}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)").WithParam("--block-id")
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
if _, err := parseSheetCellRef(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("full-comment") || strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
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 output.ErrValidation("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
|
||||
return output.ErrValidation("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>")
|
||||
return output.ErrValidation("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis and --block-id are mutually exclusive")
|
||||
return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive")
|
||||
}
|
||||
if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment cannot be used with --selection-with-ellipsis or --block-id")
|
||||
return output.ErrValidation("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
return output.ErrValidation("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 errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
|
||||
return commentDocRef{}, output.ErrValidation("--doc cannot be empty")
|
||||
}
|
||||
|
||||
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{}, 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")
|
||||
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)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
|
||||
return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw)
|
||||
}
|
||||
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
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{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)")
|
||||
}
|
||||
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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
return resolvedCommentTarget{}, output.ErrValidation("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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if objType == "slides" && mode == commentModeFull {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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{}, 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)
|
||||
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)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
|
||||
return resolvedCommentTarget{}, output.ErrValidation("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,14 +663,16 @@ func parseLocateDocResult(result map[string]interface{}) locateDocResult {
|
||||
|
||||
func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) {
|
||||
if len(result.Matches) == 0 {
|
||||
return locateDocMatch{}, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "locate-doc did not find any matching block").WithParam("--selection-with-ellipsis")
|
||||
return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block")
|
||||
}
|
||||
|
||||
if len(result.Matches) > 1 {
|
||||
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 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 result.Matches[0], 1, nil
|
||||
@@ -703,15 +705,15 @@ func summarizeLocateMatch(match locateDocMatch) string {
|
||||
|
||||
func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content cannot be empty").WithParam("--content")
|
||||
return nil, output.ErrValidation("--content cannot be empty")
|
||||
}
|
||||
|
||||
var inputs []commentReplyElementInput
|
||||
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
|
||||
return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err)
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")
|
||||
return nil, output.ErrValidation("--content must contain at least one reply element")
|
||||
}
|
||||
|
||||
replyElements := make([]map[string]interface{}, 0, len(inputs))
|
||||
@@ -722,7 +724,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
switch elementType {
|
||||
case "text":
|
||||
if strings.TrimSpace(input.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=text requires non-empty text", index).WithParam("--content")
|
||||
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
|
||||
}
|
||||
// Measure the raw rune count of the user input — that is what
|
||||
// the server actually counts. byte width and post-escape form
|
||||
@@ -732,11 +734,13 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
runes := utf8.RuneCountInString(input.Text)
|
||||
totalRunes += runes
|
||||
if totalRunes > 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")
|
||||
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),
|
||||
)
|
||||
}
|
||||
// Escape '<' and '>' so the rendered comment displays them as
|
||||
// literal characters instead of being interpreted as markup
|
||||
@@ -750,7 +754,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
case "mention_user":
|
||||
mentionUser := firstNonEmptyString(input.MentionUser, input.Text)
|
||||
if mentionUser == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=mention_user requires text or mention_user", index).WithParam("--content")
|
||||
return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index)
|
||||
}
|
||||
replyElements = append(replyElements, map[string]interface{}{
|
||||
"type": "mention_user",
|
||||
@@ -759,14 +763,14 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
case "link":
|
||||
link := firstNonEmptyString(input.Link, input.Text)
|
||||
if link == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d type=link requires text or link", index).WithParam("--content")
|
||||
return nil, output.ErrValidation("--content element #%d type=link requires text or link", index)
|
||||
}
|
||||
replyElements = append(replyElements, map[string]interface{}{
|
||||
"type": "link",
|
||||
"link": link,
|
||||
})
|
||||
default:
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type).WithParam("--content")
|
||||
return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,17 +827,17 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
func parseSlidesBlockRef(blockID string) (string, string, error) {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "slide comments require --block-id in <slide-block-type>!<xml-id> format").WithParam("--block-id")
|
||||
return "", "", output.ErrValidation("slide comments require --block-id in <slide-block-type>!<xml-id> format")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(blockID, "!", 2)
|
||||
if len(parts) != 2 {
|
||||
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 "", "", output.ErrValidation("slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID)
|
||||
}
|
||||
parsedType := strings.TrimSpace(parts[0])
|
||||
parsedID := strings.TrimSpace(parts[1])
|
||||
if parsedType == "" || parsedID == "" {
|
||||
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 "", "", output.ErrValidation("slide --block-id must be <slide-block-type>!<xml-id> (e.g. shape!bPq), got %q", blockID)
|
||||
}
|
||||
return parsedID, parsedType, nil
|
||||
}
|
||||
@@ -861,7 +865,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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input).WithParam("--block-id")
|
||||
return nil, output.ErrValidation("--block-id for sheet must be <sheetId>!<cell> (e.g. a281f9!D6), got %q", input)
|
||||
}
|
||||
sheetID := parts[0]
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
@@ -872,7 +876,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(cell) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id cell reference %q is invalid (expected e.g. D6)", cell).WithParam("--block-id")
|
||||
return nil, output.ErrValidation("--block-id cell reference %q is invalid (expected e.g. D6)", cell)
|
||||
}
|
||||
colStr := strings.ToUpper(cell[:i])
|
||||
rowStr := cell[i:]
|
||||
@@ -886,7 +890,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
|
||||
row, err := strconv.Atoi(rowStr)
|
||||
if err != nil || row < 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id row %q is invalid (must be >= 1)", rowStr).WithParam("--block-id")
|
||||
return nil, output.ErrValidation("--block-id row %q is invalid (must be >= 1)", rowStr)
|
||||
}
|
||||
row-- // convert to 0-based
|
||||
|
||||
@@ -894,7 +898,7 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) {
|
||||
}
|
||||
|
||||
func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
@@ -913,11 +917,11 @@ func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken strin
|
||||
|
||||
metas := common.GetSlice(data, "metas")
|
||||
if len(metas) == 0 {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken))
|
||||
}
|
||||
meta, ok := metas[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken))
|
||||
}
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
@@ -932,19 +936,23 @@ func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken
|
||||
return title, extension, nil
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
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")
|
||||
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(),
|
||||
)
|
||||
}
|
||||
extensionLabel := extension
|
||||
if extensionLabel == "" {
|
||||
extensionLabel = "no extension"
|
||||
}
|
||||
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")
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
func fileCommentExtension(title string) string {
|
||||
@@ -985,9 +993,9 @@ func validateFileCommentMode(mode commentMode, resolvedObjType string) error {
|
||||
return nil
|
||||
}
|
||||
if 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("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file comments only support full comments; omit --block-id and --selection-with-ellipsis")
|
||||
return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis")
|
||||
}
|
||||
|
||||
func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error {
|
||||
@@ -998,7 +1006,7 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
|
||||
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)").WithParam("--block-id")
|
||||
return output.ErrValidation("--block-id is required for sheet comments (format: <sheetId>!<cell>, e.g. a281f9!D6)")
|
||||
}
|
||||
anchor, err := parseSheetCellRef(blockID)
|
||||
if err != nil {
|
||||
@@ -1011,7 +1019,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.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1046,7 +1054,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.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1089,7 +1097,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.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
data, err := runtime.CallAPI("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,32 +9,11 @@ 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()
|
||||
|
||||
@@ -442,8 +421,14 @@ func TestParseCommentReplyElementsTextLength(t *testing.T) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||
}
|
||||
if tt.wantHint != "" {
|
||||
// Hint lives on the typed ValidationError, not err.Error().
|
||||
assertContentValidationHint(t, err, 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)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -473,11 +458,11 @@ func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected over-cap error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
hint := valErr.Hint
|
||||
hint := exitErr.Detail.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/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
return "", "", output.ErrValidation("--token is required")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
@@ -58,10 +58,10 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return "", "", output.ErrValidation(
|
||||
"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 "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return "", "", output.ErrValidation(
|
||||
"--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.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("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/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 errs.NewInternalError(errs.SubtypeInvalidResponse, "drive create_folder succeeded but returned no folder token (data.token)")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty").WithParam("--name")
|
||||
return output.ErrValidation("--name must not be empty")
|
||||
}
|
||||
if nameBytes := len([]byte(spec.Name)); nameBytes > 256 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 256 bytes (got %d)", nameBytes).WithParam("--name")
|
||||
return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes)
|
||||
}
|
||||
if spec.FolderToken != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -84,7 +84,7 @@ var DriveCreateShortcut = common.Shortcut{
|
||||
common.MaskToken(spec.FolderToken),
|
||||
)
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
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")
|
||||
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")
|
||||
}
|
||||
if spec.FileType == "folder" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: folder. The create_shortcut API only supports Drive files, not folders").WithParam("--type")
|
||||
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
|
||||
}
|
||||
if !driveCreateShortcutAllowedTypes[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 output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -313,24 +312,24 @@ func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected *errs.APIError, got %T (%v)", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitAPI)
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if string(apiErr.Subtype) != tt.wantType {
|
||||
t.Fatalf("subtype = %q, want %q", apiErr.Subtype, tt.wantType)
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
}
|
||||
if apiErr.Code != tt.code {
|
||||
t.Fatalf("code = %d, want %d", apiErr.Code, tt.code)
|
||||
if exitErr.Detail.Code != tt.code {
|
||||
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
|
||||
}
|
||||
if !strings.Contains(apiErr.Message, tt.wantMsgPart) {
|
||||
t.Fatalf("message = %q, want substring %q", apiErr.Message, tt.wantMsgPart)
|
||||
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
|
||||
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
|
||||
}
|
||||
if !strings.Contains(apiErr.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", apiErr.Hint, tt.wantHint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported").WithParam("--type")
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
|
||||
}
|
||||
if !driveDeleteAllowedTypes[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 output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
|
||||
}
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
@@ -53,10 +53,10 @@ var DriveDownload = common.Shortcut{
|
||||
|
||||
// Early path validation + overwrite check
|
||||
if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", resolveErr).WithParam("--output")
|
||||
return output.ErrValidation("unsafe output path: %s", resolveErr)
|
||||
}
|
||||
if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !overwrite {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", outputPath).WithParam("--output")
|
||||
return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
|
||||
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 wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
return output.ErrNetwork("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 driveSaveError(err)
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
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,37 +823,64 @@ 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 rel_path validation error, got nil")
|
||||
t.Fatal("expected duplicate_remote_path error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if validationErr.Hint == "" {
|
||||
t.Fatal("duplicate validation error should carry a recovery hint so AI consumers know the next action")
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
|
||||
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
|
||||
}
|
||||
if len(validationErr.Params) == 0 {
|
||||
t.Fatal("duplicate validation error should carry at least one param")
|
||||
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
|
||||
}
|
||||
var matched *errs.InvalidParam
|
||||
for i := range validationErr.Params {
|
||||
if validationErr.Params[i].Name == relPath {
|
||||
matched = &validationErr.Params[i]
|
||||
break
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
t.Fatalf("duplicate params missing rel_path group %q: %#v", relPath, validationErr.Params)
|
||||
if !matched {
|
||||
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
|
||||
}
|
||||
if matched.Reason == "" {
|
||||
t.Fatalf("duplicate param for rel_path %q missing reason", relPath)
|
||||
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)
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !strings.Contains(matched.Reason, token) {
|
||||
t.Fatalf("duplicate param reason missing token %q: %s", token, matched.Reason)
|
||||
if !strings.Contains(text, token) {
|
||||
t.Fatalf("duplicate detail missing token %q: %s", token, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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,12 +5,13 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -106,7 +107,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.CallAPITyped(
|
||||
data, err := runtime.DoAPIJSONWithLogID(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
@@ -121,11 +122,11 @@ var DriveExport = common.Shortcut{
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
@@ -206,7 +207,11 @@ var DriveExport = common.Shortcut{
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
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)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
@@ -220,7 +225,7 @@ var DriveExport = common.Shortcut{
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
@@ -233,7 +238,14 @@ var DriveExport = common.Shortcut{
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
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)
|
||||
}
|
||||
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "doc", "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType).WithParam("--doc-type")
|
||||
return output.ErrValidation("invalid --doc-type %q: allowed values are doc, docx, sheet, bitable, slides", spec.DocType)
|
||||
}
|
||||
|
||||
switch spec.FileExtension {
|
||||
case "docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension).WithParam("--file-extension")
|
||||
return output.ErrValidation("invalid --file-extension %q: allowed values are docx, pdf, xlsx, csv, markdown, base, pptx", spec.FileExtension)
|
||||
}
|
||||
|
||||
if spec.FileExtension == "markdown" && spec.DocType != "docx" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension markdown only supports --doc-type docx")
|
||||
return output.ErrValidation("--file-extension markdown only supports --doc-type docx")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "base" && spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
return output.ErrValidation("--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
return output.ErrValidation("--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
|
||||
if spec.DocType == "slides" && spec.FileExtension != "pptx" && spec.FileExtension != "pdf" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc-type slides only supports --file-extension pptx or pdf")
|
||||
return output.ErrValidation("--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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is only used when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return output.ErrValidation("--sub-id is only used when exporting sheet/bitable as csv")
|
||||
}
|
||||
if err := validate.ResourceName(spec.SubID, "--sub-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--sub-id")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if spec.FileExtension == "csv" && (spec.DocType == "sheet" || spec.DocType == "bitable") && strings.TrimSpace(spec.SubID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sub-id is required when exporting sheet/bitable as csv").WithParam("--sub-id")
|
||||
return output.ErrValidation("--sub-id is required when exporting sheet/bitable as csv")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -186,14 +186,14 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ticket := common.GetString(data, "ticket")
|
||||
if ticket == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "export task created but ticket is missing")
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --overwrite to replace)", target)
|
||||
return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil {
|
||||
return "", driveSaveError(err)
|
||||
return "", common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
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, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
return nil, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
@@ -277,24 +277,10 @@ 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, wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
return nil, output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
if apiResp.StatusCode >= 400 {
|
||||
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
|
||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
fileName := strings.TrimSpace(preferredName)
|
||||
|
||||
@@ -6,7 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -361,18 +360,12 @@ func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
|
||||
t.Fatal("expected error for missing document object, got nil")
|
||||
}
|
||||
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
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)
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
|
||||
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,18 +396,12 @@ func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
|
||||
t.Fatal("expected error for missing document.content, got nil")
|
||||
}
|
||||
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
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)
|
||||
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
|
||||
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,25 +688,21 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
|
||||
t.Fatal("expected download recovery error, got nil")
|
||||
}
|
||||
|
||||
// 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)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(valErr.Message, "already exists") {
|
||||
t.Fatalf("message missing overwrite guidance: %q", valErr.Message)
|
||||
if !strings.Contains(exitErr.Detail.Message, "already exists") {
|
||||
t.Fatalf("message missing overwrite guidance: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(valErr.Hint, "ticket=tk_ready") {
|
||||
t.Fatalf("hint missing ticket: %q", valErr.Hint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_ready") {
|
||||
t.Fatalf("hint missing ticket: %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, "file_token=box_ready") {
|
||||
t.Fatalf("hint missing file token: %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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,26 +856,18 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
t.Fatalf("stdout should stay empty on persistent poll error: %s", stdout.String())
|
||||
}
|
||||
|
||||
// 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)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
// 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.Message, "temporary backend failure") {
|
||||
t.Fatalf("message missing last poll error: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(p.Message, "temporary backend failure") {
|
||||
t.Fatalf("message missing last poll error: %q", p.Message)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "ticket=tk_poll_fail") {
|
||||
t.Fatalf("hint missing ticket: %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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, driveInputStatError(err)
|
||||
return 0, common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", spec.FilePath).WithParam("--file")
|
||||
return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath)
|
||||
}
|
||||
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/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 "", driveInputStatError(err)
|
||||
return "", common.WrapInputStatError(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 "", errs.NewInternalError(errs.SubtypeUnknown, "build upload extra failed: %v", err).WithCause(err)
|
||||
return "", output.Errorf(output.ExitInternal, "json_error", "build upload extra failed: %v", 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 errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return output.ErrValidation(
|
||||
"file %s exceeds %s import limit for .csv when importing as %s",
|
||||
common.FormatSize(fileSize),
|
||||
common.FormatSize(limit),
|
||||
docType,
|
||||
).WithParam("--file")
|
||||
)
|
||||
}
|
||||
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return output.ErrValidation(
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
|
||||
return output.ErrValidation("file must have an extension (e.g. .md, .docx, .xlsx, .pptx)")
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
case "docx", "sheet", "bitable", "slides":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType).WithParam("--type")
|
||||
return output.ErrValidation("unsupported target document type: %s. Supported types are: docx, sheet, bitable, slides", spec.DocType)
|
||||
}
|
||||
|
||||
supportedTypes, ok := driveImportExtToDocTypes[ext]
|
||||
if !ok {
|
||||
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")
|
||||
return output.ErrValidation("unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext)
|
||||
}
|
||||
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "file type mismatch: %s", hint)
|
||||
return output.ErrValidation("file type mismatch: %s", hint)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.FolderToken) != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(spec.TargetToken) != "" {
|
||||
if spec.DocType != "bitable" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-token is only supported when --type is bitable").WithParam("--target-token")
|
||||
return output.ErrValidation("--target-token is only supported when --type is bitable")
|
||||
}
|
||||
if err := validate.ResourceName(spec.TargetToken, "--target-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--target-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.CallAPITyped("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/import_tasks", nil, spec.CreateTaskBody(fileToken))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ticket := common.GetString(data, "ticket")
|
||||
if ticket == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "no ticket returned from import_tasks")
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
|
||||
return driveImportStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "import failed with status %d: %s", status.JobStatus, msg)
|
||||
}
|
||||
}
|
||||
if !hadSuccessfulPoll && lastErr != nil {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
||||
return output.ErrValidation("--url cannot be empty")
|
||||
}
|
||||
|
||||
_, ok := common.ParseResourceURL(raw)
|
||||
if !ok {
|
||||
// Not a recognized URL pattern.
|
||||
if strings.Contains(raw, "://") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
||||
return output.ErrValidation("unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw)
|
||||
}
|
||||
// Bare token: --type is required.
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
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 output.ErrValidation("--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)")
|
||||
}
|
||||
}
|
||||
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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)", objType, objToken)
|
||||
}
|
||||
|
||||
wikiNode = map[string]interface{}{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -1340,20 +1338,9 @@ func TestDriveUploadValidateRejectsConflictingTargets(t *testing.T) {
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
err := DriveUpload.Validate(context.Background(), runtime)
|
||||
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") {
|
||||
if err == nil || !strings.Contains(err.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) {
|
||||
@@ -1374,7 +1361,9 @@ func TestDriveUploadValidateRejectsExplicitEmptyWikiToken(t *testing.T) {
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
err := DriveUpload.Validate(context.Background(), runtime)
|
||||
assertDriveValidationParam(t, err, "--wiki-token", "--wiki-token cannot be empty")
|
||||
if err == nil || !strings.Contains(err.Error(), "--wiki-token cannot be empty") {
|
||||
t.Fatalf("Validate() error = %v, want empty wiki-token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
|
||||
@@ -1395,7 +1384,9 @@ func TestDriveUploadValidateRejectsExplicitEmptyFileToken(t *testing.T) {
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
err := DriveUpload.Validate(context.Background(), runtime)
|
||||
assertDriveValidationParam(t, err, "--file-token", "--file-token cannot be empty")
|
||||
if err == nil || !strings.Contains(err.Error(), "--file-token cannot be empty") {
|
||||
t.Fatalf("Validate() error = %v, want empty file-token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
|
||||
@@ -1416,25 +1407,8 @@ func TestDriveUploadValidateRejectsExplicitEmptyFolderToken(t *testing.T) {
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
|
||||
err := DriveUpload.Validate(context.Background(), runtime)
|
||||
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)
|
||||
if err == nil || !strings.Contains(err.Error(), "--folder-token cannot be empty") {
|
||||
t.Fatalf("Validate() error = %v, want empty folder-token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -74,14 +74,14 @@ var DriveMove = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if rootToken == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "get root folder token failed, root folder is empty")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 errs.NewInternalError(errs.SubtypeInvalidResponse, "move folder returned no task_id")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "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.CallAPITyped("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
|
||||
data, err := runtime.CallAPI("GET", "/open-apis/drive/explorer/v2/root_folder/meta", nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token := common.GetString(data, "token")
|
||||
if token == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root_folder/meta returned no token")
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "root_folder/meta returned no token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if strings.TrimSpace(spec.FolderToken) != "" {
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
}
|
||||
if !driveMoveAllowedTypes[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 output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, slides", spec.FileType)
|
||||
}
|
||||
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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
|
||||
return driveTaskCheckStatus{}, output.ErrValidation("%s", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/drive/v1/files/task_check", driveTaskCheckParams(taskID), nil)
|
||||
data, err := runtime.CallAPI("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, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
|
||||
return status, false, output.Errorf(output.ExitAPI, "api_error", "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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return driveInputStatError(err)
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-local requires --yes (high-risk: deletes local files absent from Drive)").WithParam("--yes")
|
||||
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
|
||||
}
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
||||
return output.ErrValidation("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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir resolves outside cwd: %s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
|
||||
}
|
||||
|
||||
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 errs.WrapInternal(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
@@ -293,25 +293,26 @@ 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. 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.
|
||||
// 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.
|
||||
if failed > 0 {
|
||||
note := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
|
||||
msg := fmt.Sprintf("%d item(s) failed during +pull; partial sync — re-run after resolving the failures", failed)
|
||||
if deleteLocal && downloadFailed > 0 {
|
||||
note += " (--delete-local was skipped because the download pass had failures)"
|
||||
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,
|
||||
},
|
||||
}
|
||||
payload["note"] = note
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return runtime.OutPartialFailure(payload, nil)
|
||||
}
|
||||
runtime.Out(payload, nil)
|
||||
return nil
|
||||
},
|
||||
@@ -325,14 +326,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 wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
|
||||
return output.ErrNetwork("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 driveSaveError(err)
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
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)
|
||||
@@ -349,10 +350,10 @@ func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(target)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err)
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot preserve remote modified_time on local file: %s", err).WithCause(err)
|
||||
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -436,7 +437,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, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
}
|
||||
}
|
||||
return remoteFiles, remotePaths, nil
|
||||
@@ -466,7 +467,7 @@ func drivePullWalkLocal(root string) ([]string, error) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, 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 partial-failure (ok:false items[] payload
|
||||
// on stdout + non-zero exit) under both skip and overwrite policies so callers
|
||||
// can react via exit code.
|
||||
// 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.
|
||||
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)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
detail := assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullDetail(t, detail)
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("[%s] summary.failed = %v, want 1", policy, got)
|
||||
}
|
||||
@@ -529,6 +529,9 @@ 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -897,8 +900,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 non-zero exit (partial-failure signal) so callers can detect
|
||||
// the half-synced state via exit code. Before the fix, the delete_failed
|
||||
// surfaces as a partial_failure ExitError 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
|
||||
@@ -944,8 +947,8 @@ func TestDrivePullDeleteLocalCountsFailureInSummary(t *testing.T) {
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
detail := assertDrivePullPartialFailure(t, err)
|
||||
summary, items := splitDrivePullDetail(t, detail)
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1 (delete_failed must increment failed)", got)
|
||||
}
|
||||
@@ -955,12 +958,15 @@ 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 via the partial-failure signal. The half-synced state
|
||||
// MUST exit non-zero with type=partial_failure. 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) {
|
||||
@@ -1008,12 +1014,12 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) {
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
summary, items := splitDrivePullStdout(t, stdout.Bytes())
|
||||
summary, items := splitDrivePullDetail(t, exitErr)
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Errorf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
@@ -1030,6 +1036,9 @@ 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
|
||||
@@ -1334,60 +1343,49 @@ func mustReadFile(t *testing.T, path, want string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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 {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected partial-failure exit signal, got nil")
|
||||
t.Fatal("expected partial_failure ExitError, got nil")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", exitErr.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
|
||||
}
|
||||
|
||||
// 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{}) {
|
||||
// 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{}) {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
} `json:"data"`
|
||||
raw, err := json.Marshal(exitErr.Detail.Detail)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal detail: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
||||
var got struct {
|
||||
Summary map[string]interface{} `json:"summary"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
if envelope.Data.Summary == nil {
|
||||
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal detail: %v\nraw=%s", err, string(raw))
|
||||
}
|
||||
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
|
||||
if got.Summary == nil {
|
||||
t.Fatalf("error.detail missing summary; raw=%s", string(raw))
|
||||
}
|
||||
return got.Summary, got.Items
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,7 +19,6 @@ 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return driveInputStatError(err)
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
if runtime.Bool("delete-remote") && !runtime.Bool("yes") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--delete-remote requires --yes (high-risk: deletes Drive files absent locally)").WithParam("--yes")
|
||||
return output.ErrValidation("--delete-remote requires --yes (high-risk: deletes Drive files absent locally)")
|
||||
}
|
||||
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
||||
return output.ErrValidation("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 errs.WrapInternal(err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "%s", err)
|
||||
}
|
||||
|
||||
var uploaded, skipped, failed, deletedRemote int
|
||||
@@ -374,7 +374,7 @@ var DrivePush = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
runtime.Out(map[string]interface{}{
|
||||
"summary": map[string]interface{}{
|
||||
"uploaded": uploaded,
|
||||
"skipped": skipped,
|
||||
@@ -382,15 +382,15 @@ var DrivePush = common.Shortcut{
|
||||
"deleted_remote": deletedRemote,
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
// 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.
|
||||
}, 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 runtime.OutPartialFailure(payload, nil)
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
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, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
|
||||
return nil, nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, 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, errs.NewInternalError(errs.SubtypeUnknown, "unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
return nil, nil, nil, fmt.Errorf("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.CallAPITyped(
|
||||
data, err := runtime.CallAPI(
|
||||
"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 "", errs.NewInternalError(errs.SubtypeInvalidResponse, "create_folder for %q returned no folder token", relDir)
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "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 "", "", driveInputStatError(err)
|
||||
return "", "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -644,22 +644,27 @@ func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", wrapDriveNetworkErr(err, "upload failed: %v", err)
|
||||
return "", "", output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
// entry.FileToken and silently lose the token Drive actually used,
|
||||
// defeating the overwrite-error token-stability handling in Execute.
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
token := common.GetString(data, "file_token")
|
||||
if err != nil {
|
||||
return token, "", err
|
||||
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 token == "" {
|
||||
return "", "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
return "", "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
@@ -672,7 +677,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, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
return token, "", output.Errorf(output.ExitAPI, "api_error", "overwrite for %q succeeded but no version was returned by upload_all", file.RelPath)
|
||||
}
|
||||
return token, version, nil
|
||||
}
|
||||
@@ -687,7 +692,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
if existingToken != "" {
|
||||
prepareBody["file_token"] = existingToken
|
||||
}
|
||||
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -696,7 +701,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 "", errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
return "", output.Errorf(output.ExitAPI, "api_error",
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
uploadID, blockSize, blockNum)
|
||||
}
|
||||
@@ -712,7 +717,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 "", driveInputStatError(err)
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer partFile.Close()
|
||||
|
||||
@@ -739,16 +744,21 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
if errors.As(doErr, &exitErr) {
|
||||
return "", doErr
|
||||
}
|
||||
return "", wrapDriveNetworkErr(doErr, "upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
return "", output.ErrNetwork("upload part %d/%d failed: %v", seq+1, blockNum, doErr)
|
||||
}
|
||||
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return "", err
|
||||
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"])
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
@@ -757,7 +767,7 @@ func drivePushUploadMultipart(_ context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
token := common.GetString(finishResult, "file_token")
|
||||
if token == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload_finish succeeded but no file_token returned")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -766,7 +776,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.CallAPITyped(
|
||||
_, err := runtime.CallAPI(
|
||||
"DELETE",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(fileToken)),
|
||||
map[string]interface{}{"type": driveTypeFile},
|
||||
|
||||
@@ -871,19 +871,21 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
|
||||
"--if-exists", "overwrite",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
// 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.
|
||||
// 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.
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
|
||||
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)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
@@ -957,19 +959,12 @@ 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 pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1047,9 +1042,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 pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI ExitError, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
@@ -1070,7 +1065,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
|
||||
// partial-failure path could regress to "always non-zero" silently.
|
||||
// ErrBare-on-failure path could regress to "always non-zero" silently.
|
||||
func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package drive
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -14,7 +15,6 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
|
||||
return nil, nil, output.ErrValidation("cannot combine --mine and --creator-ids")
|
||||
}
|
||||
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
|
||||
return nil, nil, output.ErrValidation("cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
|
||||
}
|
||||
if spec.Mine && userOpenID == "" {
|
||||
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")
|
||||
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")
|
||||
}
|
||||
|
||||
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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be a number, got %q", raw).WithParam("--page-size")
|
||||
return 0, output.ErrValidation("--page-size must be a number, got %q", raw)
|
||||
}
|
||||
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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
|
||||
return output.ErrValidation("--creator-ids %q: %s", id, err)
|
||||
}
|
||||
}
|
||||
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
|
||||
return output.ErrValidation("--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n)
|
||||
}
|
||||
for _, id := range spec.ChatIDs {
|
||||
if _, err := common.ValidateChatID(id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
|
||||
return output.ErrValidation("--chat-ids %q: %s", id, err)
|
||||
}
|
||||
}
|
||||
if n := len(spec.SharerIDs); n > driveSearchMaxSharerIDs {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
|
||||
return output.ErrValidation("--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n)
|
||||
}
|
||||
for _, id := range spec.SharerIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
|
||||
return output.ErrValidation("--sharer-ids %q: %s", id, err)
|
||||
}
|
||||
}
|
||||
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 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 output.ErrValidation("--doc-types contains unknown value %q (allowed: doc,sheet,bitable,mindnote,file,wiki,docx,folder,catalog,slides,shortcut)", v)
|
||||
}
|
||||
}
|
||||
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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-since %q: %s", spec.OpenedSince, err).WithParam("--opened-since")
|
||||
return "", output.ErrValidation("invalid --opened-since %q: %s", spec.OpenedSince, err)
|
||||
}
|
||||
var untilUnix int64
|
||||
if spec.OpenedUntil != "" {
|
||||
untilUnix, err = parseTimeValue(spec.OpenedUntil, now)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --opened-until %q: %s", spec.OpenedUntil, err).WithParam("--opened-until")
|
||||
return "", output.ErrValidation("invalid --opened-until %q: %s", spec.OpenedUntil, err)
|
||||
}
|
||||
} 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 "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return "", output.ErrValidation(
|
||||
"--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, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-since %q: %s", timeDimCLIName(key), since, err).WithParam(fmt.Sprintf("--%s-since", timeDimCLIName(key)))
|
||||
return nil, nil, output.ErrValidation("invalid --%s-since %q: %s", timeDimCLIName(key), since, err)
|
||||
}
|
||||
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, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --%s-until %q: %s", timeDimCLIName(key), until, err).WithParam(fmt.Sprintf("--%s-until", timeDimCLIName(key)))
|
||||
return nil, nil, output.ErrValidation("invalid --%s-until %q: %s", timeDimCLIName(key), until, err)
|
||||
}
|
||||
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") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
|
||||
return 0, fmt.Errorf("empty value")
|
||||
}
|
||||
|
||||
if m := driveSearchRelativeRe.FindStringSubmatch(s); m != nil {
|
||||
@@ -616,27 +616,34 @@ 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") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError
|
||||
return 0, fmt.Errorf("expected relative (7d/1m/1y), date (YYYY-MM-DD[ HH:MM:SS]), RFC3339, or unix seconds")
|
||||
}
|
||||
|
||||
func callDriveSearchAPI(runtime *common.RuntimeContext, reqBody map[string]interface{}) (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, reqBody)
|
||||
data, err := runtime.CallAPI("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 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.
|
||||
// enrichDriveSearchError adds a +search-specific hint for known opaque Lark
|
||||
// codes; other errors pass through unchanged.
|
||||
func enrichDriveSearchError(err error) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != driveSearchErrUserNotVisible {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return 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
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneDriveSearchFilter(src map[string]interface{}) map[string]interface{} {
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -260,19 +258,6 @@ 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) {
|
||||
@@ -640,39 +625,51 @@ func TestEnrichDriveSearchError(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed error with non-matching code passes through", func(t *testing.T) {
|
||||
t.Run("ExitError without Detail passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
orig := errclass.BuildAPIError(
|
||||
map[string]any{"code": float64(12345), "msg": "other"},
|
||||
errclass.ClassifyContext{},
|
||||
)
|
||||
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"},
|
||||
}
|
||||
if got := enrichDriveSearchError(orig); got != orig {
|
||||
t.Fatalf("non-matching code should pass through unchanged")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching code decorates the typed error's hint in place", func(t *testing.T) {
|
||||
t.Run("matching code rewrites Hint without mutating original", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
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).
|
||||
orig := &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{
|
||||
Code: driveSearchErrUserNotVisible,
|
||||
Message: "[99992351] user not visible",
|
||||
Hint: "",
|
||||
},
|
||||
}
|
||||
enriched := enrichDriveSearchError(orig)
|
||||
if enriched != orig {
|
||||
t.Fatal("should decorate and return the upstream error, not construct a new one")
|
||||
}
|
||||
p, ok := errs.ProblemOf(enriched)
|
||||
eErr, ok := enriched.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T", enriched)
|
||||
t.Fatalf("expected *output.ExitError, got %T", enriched)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--creator-ids") {
|
||||
t.Fatalf("hint should mention --creator-ids, got %q", p.Hint)
|
||||
if eErr == orig {
|
||||
t.Fatal("should return a new ExitError, not mutate the original")
|
||||
}
|
||||
if p.Message != "[99992351] user not visible" {
|
||||
t.Fatalf("Message should be preserved, got %q", p.Message)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -742,18 +739,6 @@ 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/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 10").WithParam("--page-size")
|
||||
return output.ErrValidation("--page-size must be between 1 and 10")
|
||||
}
|
||||
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.CallAPITyped("GET",
|
||||
data, err := runtime.CallAPI("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.CallAPITyped("PATCH",
|
||||
data, err := runtime.CallAPI("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/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is required").WithParam("--local-dir")
|
||||
return common.FlagErrorf("--local-dir is required")
|
||||
}
|
||||
if folderToken == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--folder-token is required").WithParam("--folder-token")
|
||||
return common.FlagErrorf("--folder-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(localDir)
|
||||
if err != nil {
|
||||
return driveInputStatError(err)
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir is not a directory: %s", localDir).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
|
||||
}
|
||||
// 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--local-dir: %s", err).WithParam("--local-dir")
|
||||
return output.ErrValidation("--local-dir: %s", err)
|
||||
}
|
||||
cwdCanonical, err := validate.SafeInputPath(".")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "could not resolve cwd: %s", err)
|
||||
return output.ErrValidation("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, errs.NewInternalError(errs.SubtypeFileIO, "walk %s: %s", root, err).WithCause(err)
|
||||
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, 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 "", driveInputStatError(err)
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "hash %s: %s", path, err).WithCause(err)
|
||||
return "", output.Errorf(output.ExitInternal, "io", "hash %s: %s", path, 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 "", wrapDriveNetworkErr(err, "download %s: %s", common.MaskToken(fileToken), err)
|
||||
return "", output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, resp.Body); err != nil {
|
||||
return "", wrapDriveNetworkErr(err, "hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
return "", output.ErrNetwork("hash remote %s: %s", common.MaskToken(fileToken), err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -822,15 +822,12 @@ func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected walkLocalForStatus() to fail for missing root")
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected structured ExitError, got %T", err)
|
||||
}
|
||||
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 exitErr.Detail == nil || exitErr.Detail.Type != "io" {
|
||||
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "walk") {
|
||||
t.Fatalf("expected walk-related error, got: %v", err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user