mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
33 Commits
feat/7564d
...
feat/short
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd34b1f70 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef | ||
|
|
0aa9e96d18 | ||
|
|
e57d97f341 | ||
|
|
57ba4fae61 | ||
|
|
925ae5ecd6 | ||
|
|
4710a294f5 | ||
|
|
bc8e9bd6ef | ||
|
|
f65712cacf | ||
|
|
915cc623cc | ||
|
|
3bfb80951d | ||
|
|
639259fbfd | ||
|
|
0bdd7de807 | ||
|
|
9a53a1f2b8 | ||
|
|
eb6f5aa60a | ||
|
|
c4eb18cecc | ||
|
|
a510e07dfc | ||
|
|
f83c79825d | ||
|
|
9adc79d0c1 | ||
|
|
6b4bc0cc64 | ||
|
|
b5cd535285 | ||
|
|
098659cc18 | ||
|
|
8d8acb8252 | ||
|
|
ccf654d3f0 | ||
|
|
ad4368ed2a | ||
|
|
a07239b923 |
@@ -65,10 +65,23 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
|
||||
# still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -94,6 +107,23 @@ linters:
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on drive ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Drive has migrated its
|
||||
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
|
||||
# this prevents reintroduction. Other domains still use the shared
|
||||
# helpers (migrated globally in a later phase), so this is drive-scoped.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use the typed errs.NewXxxError builders or the drive-local
|
||||
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -2,6 +2,47 @@
|
||||
|
||||
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
|
||||
|
||||
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
|
||||
- **platform**: Support multiple policy rules per plugin (#1182)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
|
||||
- **whiteboard**: Fix whiteboard skill (#1180)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Update login hint and split-flow docs (#1201)
|
||||
|
||||
## [v1.0.44] - 2026-05-29
|
||||
|
||||
### Features
|
||||
@@ -948,6 +989,8 @@ 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
|
||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||
|
||||
@@ -279,7 +279,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
||||
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
||||
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
||||
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
||||
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
||||
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
||||
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -521,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.Service, brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
@@ -551,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.GetService()) {
|
||||
domains[sc.GetService()] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
@@ -574,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.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,13 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
svc := sc.GetService()
|
||||
if !seen[svc] {
|
||||
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
|
||||
dm := buildDomainMeta(svc, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
seen[sc.Service] = true
|
||||
seen[svc] = 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'")
|
||||
}
|
||||
@@ -1042,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
"come back and notify",
|
||||
"YOU must execute",
|
||||
"lark-cli auth login --device-code <device_code>",
|
||||
"Do NOT cache",
|
||||
"lark-cli auth login --no-wait --json",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
|
||||
@@ -341,6 +341,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -380,6 +383,9 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -419,6 +425,11 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if result.AppSecret != "" {
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -507,5 +518,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
@@ -177,7 +177,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
httpClient := &http.Client{}
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
|
||||
91
cmd/config/init_probe.go
Normal file
91
cmd/config/init_probe.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
// (covering both TAT acquisition and the subsequent probe request).
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// runProbe runs a best-effort credential validation after config init has
|
||||
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||
// deterministic credential-rejection signal; every other outcome returns nil
|
||||
// so that valid configurations and transient/upstream noise never block the
|
||||
// command.
|
||||
//
|
||||
// The function performs up to two HTTP calls in series, bounded by
|
||||
// probeTimeout:
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||
if err != nil {
|
||||
// A typed error from FetchTAT is a deterministic credential rejection
|
||||
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||
// silent and let the command succeed.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
288
cmd/config/init_probe_test.go
Normal file
288
cmd/config/init_probe_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
f.probeCalls++
|
||||
f.probeReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
f.probeBody = string(b)
|
||||
}
|
||||
if f.probeHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||
// the caller-supplied RoundTripper.
|
||||
//
|
||||
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||
// touch lands in a temp dir rather than the developer's real config.
|
||||
//
|
||||
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||
// this buffer stays empty as an invariant.
|
||||
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
}
|
||||
return f, errBuf
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("expected nil (silent), got error: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||
// rejection) → silent, exit 0.
|
||||
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500} {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(code, `nope`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(500, `server error`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if rt.probeReq.Method != http.MethodPost {
|
||||
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||
}
|
||||
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||
t.Errorf("probe URL = %s", got)
|
||||
}
|
||||
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||
}
|
||||
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||
}
|
||||
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
if s.GetService() != "" {
|
||||
seen[s.GetService()] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Method: sc.GetCommand(),
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
@@ -148,11 +148,12 @@ func diagBuild(domains []string) diagOutput {
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
for _, a := range authTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
@@ -152,7 +153,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
type probeResult struct {
|
||||
|
||||
@@ -105,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
if sc.GetService() != service || sc.GetCommand() != 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.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
|
||||
21
cmd/root.go
21
cmd/root.go
@@ -48,20 +48,6 @@ EXAMPLES:
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
@@ -255,6 +241,13 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
|
||||
@@ -155,7 +155,30 @@ caller scripts.
|
||||
|
||||
New code should not reach for `ErrBare` unless the command is
|
||||
genuinely a predicate. Anything carrying recoverable error content
|
||||
belongs in a typed `*errs.XxxError`.
|
||||
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
|
||||
partial-failure outcome below.
|
||||
|
||||
### Partial failure (batch / multi-status)
|
||||
|
||||
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
|
||||
many items can finish in a third state, neither full success nor a single
|
||||
error: some items succeeded and some failed. Its primary output is the
|
||||
per-item result, so it does **not** belong in a `stderr` error envelope.
|
||||
|
||||
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
|
||||
|
||||
1. writes the full result to **stdout** as an `ok:false` envelope — the
|
||||
summary and every per-item outcome (succeeded *and* failed) stay
|
||||
machine-readable, exactly as a successful `Out(...)` would carry them,
|
||||
but with `ok` honestly reporting failure; and
|
||||
2. returns `*output.PartialFailureError`, a typed exit signal the
|
||||
dispatcher maps to a non-zero exit code while writing nothing further
|
||||
to `stderr`.
|
||||
|
||||
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
|
||||
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
|
||||
*result*, reported on stdout, that also failed. Consumers branch on
|
||||
`ok == false` and then read `data.summary` / `data.items[]`.
|
||||
|
||||
## Consumers
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
14
errs/subtypes_shortcut.go
Normal file
14
errs/subtypes_shortcut.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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"
|
||||
)
|
||||
25
errs/subtypes_shortcut_test.go
Normal file
25
errs/subtypes_shortcut_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// 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,8 +61,22 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
|
||||
// item (RFC 7807 §3.1 extension members).
|
||||
//
|
||||
// The wire key on ValidationError is "params" rather than "invalid_params"
|
||||
// because the enclosing envelope already carries type:"validation", so the
|
||||
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
|
||||
// InvalidParam prefix because, at package level, the name must self-describe.
|
||||
type InvalidParam struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
@@ -122,6 +136,11 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
|
||||
e.Params = append(e.Params, params...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -558,6 +558,71 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidationError_WithParams covers the structured-validation extension:
|
||||
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
|
||||
// and the wire shape nests {name, reason} under "params" (omitted when empty).
|
||||
func TestValidationError_WithParams(t *testing.T) {
|
||||
t.Run("appends and exposes fields", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
if len(e.Params) != 1 {
|
||||
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
|
||||
}
|
||||
if e.Params[0].Name != "a.md" {
|
||||
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
|
||||
}
|
||||
if e.Params[0].Reason != "duplicate" {
|
||||
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
|
||||
if returned != e {
|
||||
t.Errorf("WithParams returned different pointer; want same as receiver")
|
||||
}
|
||||
e.WithParams(
|
||||
errs.InvalidParam{Name: "b.md", Reason: "dup"},
|
||||
errs.InvalidParam{Name: "c.md", Reason: "dup"},
|
||||
)
|
||||
if len(e.Params) != 3 {
|
||||
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParam("--rel-path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"param":"--rel-path"`,
|
||||
`"params":[{"name":"a.md","reason":"duplicate"}]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Params omitted from wire", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), `"params"`) {
|
||||
t.Errorf("empty Params should be omitted from wire; got %s", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
||||
scopes := []string{"docx:document", "im:message:send"}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.SharedTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
transport = wrapWithExtension(transport)
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
rt = wrapWithExtension(rt)
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Transport: rt,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 256
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
||||
|
||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||
// the transport still sets X-Cli-Build and routes the request through
|
||||
// util.FallbackTransport rather than panicking. This covers the fallback
|
||||
// transport.Fallback rather than panicking. This covers the fallback
|
||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||
var receivedBuild string
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -166,42 +164,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": acct.AppID,
|
||||
"app_secret": acct.AppSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
|
||||
}
|
||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||
return &TokenResult{Token: token}, nil
|
||||
}
|
||||
|
||||
70
internal/credential/tat_fetch.go
Normal file
70
internal/credential/tat_fetch.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
237
internal/credential/tat_fetch_test.go
Normal file
237
internal/credential/tat_fetch_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||
type stubRoundTripper struct {
|
||||
gotReq *http.Request
|
||||
gotBody string
|
||||
respCode int
|
||||
respBody string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
s.gotReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
s.gotBody = string(b)
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: s.respCode,
|
||||
Body: io.NopCloser(strings.NewReader(s.respBody)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d: expected error", code)
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
|
||||
sentinel := errors.New("network down")
|
||||
rt := &stubRoundTripper{err: sentinel}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("transport error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("error chain missing sentinel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("parse error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rt.gotReq.URL.String(); got != tc.wantURL {
|
||||
t.Errorf("url = %s, want %s", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ContextCanceled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rt := &urlRewriteRT{base: srv.URL}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-canceled
|
||||
|
||||
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for canceled context")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("error chain missing context.Canceled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// urlRewriteRT forwards requests to a fixed base URL (test server).
|
||||
type urlRewriteRT struct{ base string }
|
||||
|
||||
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
newURL := r.base + req.URL.Path
|
||||
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req2.Header = req.Header
|
||||
return http.DefaultTransport.RoundTrip(req2)
|
||||
}
|
||||
@@ -20,4 +20,8 @@ const (
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
CliCAPath = "LARKSUITE_CLI_CA_PATH"
|
||||
)
|
||||
|
||||
@@ -129,6 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
@@ -231,6 +232,22 @@ func ConfigHint(subtype errs.Subtype) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||
func APIHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeConflict:
|
||||
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "operate on source and target within the same tenant and region/unit"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "operate on source and target within the same brand environment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
|
||||
17
internal/errclass/codemeta_drive.go
Normal file
17
internal/errclass/codemeta_drive.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
43
internal/errclass/codemeta_drive_test.go
Normal file
43
internal/errclass/codemeta_drive_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
|
||||
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
|
||||
// Each case traces to repo evidence (see codemeta_drive.go comments).
|
||||
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 1061044: upload with a nonexistent parent folder token. The drive E2E
|
||||
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
|
||||
// producer via a nonexistent parent folder → referenced resource missing.
|
||||
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,28 @@ func ErrBare(code int) *ExitError {
|
||||
return &ExitError{Code: code}
|
||||
}
|
||||
|
||||
// PartialFailureError is the exit signal for a batch / multi-status command that
|
||||
// has already written an ok:false result envelope to stdout. The per-item
|
||||
// outcomes are the primary, machine-readable output and live on stdout, so the
|
||||
// dispatcher sets only the exit code and writes nothing to stderr.
|
||||
//
|
||||
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
|
||||
// so the predicate contract stays narrow, and from a typed *errs.XxxError
|
||||
// (which owns the stderr error envelope): a partial failure is a result, not an
|
||||
// error envelope.
|
||||
type PartialFailureError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func (e *PartialFailureError) Error() string {
|
||||
return fmt.Sprintf("partial failure (exit %d)", e.Code)
|
||||
}
|
||||
|
||||
// PartialFailure builds the partial-failure exit signal with the given code.
|
||||
func PartialFailure(code int) *PartialFailureError {
|
||||
return &PartialFailureError{Code: code}
|
||||
}
|
||||
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
|
||||
@@ -61,6 +61,10 @@ func ExitCodeOf(err error) int {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var pfErr *PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -178,7 +179,9 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
// localVersion is sent as data_version query param for server-side version comparison.
|
||||
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
|
||||
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
||||
client := &http.Client{Timeout: fetchTimeout}
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -270,6 +270,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
|
||||
@@ -306,6 +306,39 @@ func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
243
internal/transport/config.go
Normal file
243
internal/transport/config.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
||||
//
|
||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
||||
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
|
||||
// proxies, and fails closed on misconfiguration. Environment variables override
|
||||
// matching values from proxy_config.json.
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ConfigFileName is the fixed config file name under core.GetConfigDir().
|
||||
const (
|
||||
ConfigFileName = "proxy_config.json"
|
||||
)
|
||||
|
||||
// Config is the on-disk config format. Keys intentionally mirror env var names.
|
||||
type Config struct {
|
||||
// Enable turns on proxy plugin transport handling.
|
||||
Enable bool `json:"LARKSUITE_CLI_PROXY_ENABLE"`
|
||||
|
||||
// Proxy is the fixed HTTP proxy address used for all outbound requests.
|
||||
Proxy string `json:"LARKSUITE_CLI_PROXY_ADDRESS"`
|
||||
|
||||
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
|
||||
CAPath string `json:"LARKSUITE_CLI_CA_PATH"`
|
||||
}
|
||||
|
||||
// Path returns the absolute path to the proxy plugin config file.
|
||||
func Path() string {
|
||||
return filepath.Join(core.GetConfigDir(), ConfigFileName)
|
||||
}
|
||||
|
||||
// loadOnce guards one-time proxy config loading for process-wide transport reuse.
|
||||
var loadOnce sync.Once
|
||||
|
||||
// loadCfg stores the cached proxy config after the first successful Load call.
|
||||
var loadCfg *Config
|
||||
|
||||
// loadErr stores the cached Load error observed during the first load attempt.
|
||||
var loadErr error
|
||||
|
||||
// Load reads ~/.lark-cli/proxy_config.json once and caches the parsed result.
|
||||
// Environment variables (CliProxyEnable/CliProxyAddress/CliCAPath) take precedence over config file values.
|
||||
//
|
||||
// Returns (nil, nil) only when:
|
||||
// - the config file does not exist AND
|
||||
// - none of the proxy-related env vars are present.
|
||||
func Load() (*Config, error) {
|
||||
loadOnce.Do(func() {
|
||||
// Start from env-only config if any proxy env var is present.
|
||||
cfg, hasEnv, err := loadFromEnv()
|
||||
if err != nil {
|
||||
loadErr = err
|
||||
return
|
||||
}
|
||||
|
||||
p := Path()
|
||||
if _, err := vfs.Stat(p); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// No file: return env-only config (if any), else nil.
|
||||
if hasEnv {
|
||||
loadCfg = cfg
|
||||
} else {
|
||||
loadCfg = nil
|
||||
}
|
||||
loadErr = nil
|
||||
return
|
||||
}
|
||||
loadErr = fmt.Errorf("failed to stat proxy plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
// Security hardening: this config dictates where ALL outbound CLI traffic
|
||||
// egresses and which extra CA is trusted, so a file another local user or
|
||||
// process can tamper with (symlink, foreign owner, group/world-writable)
|
||||
// could redirect credential traffic. Audit it the same way the CA file is.
|
||||
safePath, err := binding.AssertSecurePath(binding.AuditParams{
|
||||
TargetPath: p,
|
||||
Label: ConfigFileName,
|
||||
AllowReadableByOthers: true, // config is not a secret; only writability/owner/symlink matter
|
||||
})
|
||||
if err != nil {
|
||||
loadErr = fmt.Errorf("unsafe proxy plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
b, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
loadErr = fmt.Errorf("failed to read proxy plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
var fileCfg Config
|
||||
if err := json.Unmarshal(b, &fileCfg); err != nil {
|
||||
loadErr = fmt.Errorf("invalid proxy plugin config %q: %w", p, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge: file base + env overrides.
|
||||
if cfg == nil {
|
||||
cfg = &fileCfg
|
||||
} else {
|
||||
*cfg = fileCfg
|
||||
applyEnvOverrides(cfg)
|
||||
}
|
||||
loadCfg = cfg
|
||||
})
|
||||
return loadCfg, loadErr
|
||||
}
|
||||
|
||||
// Enabled reports whether proxy plugin mode is enabled.
|
||||
func (c *Config) Enabled() bool { return c != nil && c.Enable }
|
||||
|
||||
// loadFromEnv builds a config from proxy-related environment variables only.
|
||||
// It reports whether any proxy-related environment variable was present.
|
||||
func loadFromEnv() (*Config, bool, error) {
|
||||
_, hasEnable := os.LookupEnv(envvars.CliProxyEnable)
|
||||
_, hasProxy := os.LookupEnv(envvars.CliProxyAddress)
|
||||
_, hasCA := os.LookupEnv(envvars.CliCAPath)
|
||||
hasAny := hasEnable || hasProxy || hasCA
|
||||
if !hasAny {
|
||||
return nil, false, nil
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := applyEnvOverrides(cfg); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return cfg, true, nil
|
||||
}
|
||||
|
||||
// applyEnvOverrides copies proxy-related environment variable values into cfg.
|
||||
func applyEnvOverrides(cfg *Config) error {
|
||||
if v, ok := os.LookupEnv(envvars.CliProxyEnable); ok {
|
||||
b, err := parseBoolEnv(envvars.CliProxyEnable, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Enable = b
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliProxyAddress); ok {
|
||||
cfg.Proxy = v
|
||||
}
|
||||
if v, ok := os.LookupEnv(envvars.CliCAPath); ok {
|
||||
cfg.CAPath = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseBoolEnv accepts common boolean spellings used in environment variables.
|
||||
func parseBoolEnv(name, raw string) (bool, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
// Treat empty as false when explicitly present.
|
||||
return false, nil
|
||||
}
|
||||
switch s {
|
||||
case "1", "true", "on", "yes", "y":
|
||||
return true, nil
|
||||
case "0", "false", "off", "no", "n":
|
||||
return false, nil
|
||||
}
|
||||
if b, err := strconv.ParseBool(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
|
||||
}
|
||||
|
||||
// proxyURL validates the fixed configured proxy configuration and returns its URL.
|
||||
func (c *Config) proxyURL() (*url.URL, error) {
|
||||
raw := strings.TrimSpace(c.Proxy)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("%s is empty", envvars.CliProxyAddress)
|
||||
}
|
||||
redacted := redactProxyURL(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
// Do not wrap the raw url.Parse error: its string embeds the original
|
||||
// URL, which can contain userinfo (user:password). Return a redacted,
|
||||
// generic message instead.
|
||||
return nil, fmt.Errorf("invalid %s %q: malformed URL", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
// Security hardening: only allow a loopback proxy. This prevents accidental
|
||||
// cross-machine proxying of credentials/traffic.
|
||||
if u.Hostname() != "127.0.0.1" {
|
||||
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
if u.Port() == "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
if u.Path != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliProxyAddress, redacted)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ApplyToTransport clones base and applies proxy plugin settings to the clone.
|
||||
// Caller owns the returned *http.Transport.
|
||||
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport.(*http.Transport)
|
||||
}
|
||||
u, err := c.proxyURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := base.Clone()
|
||||
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
|
||||
if err := applyExtraRootCA(t, c.CAPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
372
internal/transport/config_test.go
Normal file
372
internal/transport/config_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// unsetEnv clears key for the duration of the test and restores its original value.
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, had := os.LookupEnv(key)
|
||||
_ = os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if had {
|
||||
_ = os.Setenv(key, old)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests.
|
||||
func unsetProxyPluginEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
unsetEnv(t, envvars.CliProxyEnable)
|
||||
unsetEnv(t, envvars.CliProxyAddress)
|
||||
unsetEnv(t, envvars.CliCAPath)
|
||||
}
|
||||
|
||||
// writeFile creates parent directories and writes test data for fixtures.
|
||||
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, perm); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
|
||||
// or proxy environment overrides exist.
|
||||
func TestLoad_MissingFileReturnsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetProxyPluginEnv(t)
|
||||
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
|
||||
// because multiple tests in this package share the package-level Load()
|
||||
// cache via sync.Once.
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyToTransport_SetsProxy verifies that a valid proxy config installs a fixed proxy.
|
||||
func TestApplyToTransport_SetsProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_CA_PATH": ""
|
||||
}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
|
||||
base := http.DefaultTransport.(*http.Transport)
|
||||
tr, err := cfg.ApplyToTransport(base)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyToTransport() error = %v", err)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("Proxy func is nil, want fixed proxy")
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:3128" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_RejectsNonLoopbackProxy verifies that proxy mode rejects non-loopback proxies.
|
||||
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://10.0.0.1:3128",
|
||||
"LARKSUITE_CLI_CA_PATH": ""
|
||||
}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
|
||||
if err == nil {
|
||||
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the configured proxy validator
|
||||
// rejects URLs with missing ports, paths, queries, and fragments.
|
||||
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing explicit port",
|
||||
raw: "http://127.0.0.1",
|
||||
want: "explicit port is required",
|
||||
},
|
||||
{
|
||||
name: "trailing slash path",
|
||||
raw: "http://127.0.0.1:3128/",
|
||||
want: "path is not allowed",
|
||||
},
|
||||
{
|
||||
name: "query string",
|
||||
raw: "http://127.0.0.1:3128?foo=bar",
|
||||
want: "query is not allowed",
|
||||
},
|
||||
{
|
||||
name: "fragment",
|
||||
raw: "http://127.0.0.1:3128#frag",
|
||||
want: "fragment is not allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := (&Config{Proxy: tt.raw}).proxyURL()
|
||||
if err == nil {
|
||||
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_EnvOnlyConfig verifies that proxy settings can come entirely from environment variables.
|
||||
func TestLoad_EnvOnlyConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
|
||||
t.Setenv(envvars.CliProxyEnable, "true")
|
||||
t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:7777")
|
||||
t.Setenv(envvars.CliCAPath, "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyToTransport() error = %v", err)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:7777" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_EnvOverridesFile verifies that proxy environment variables override file values.
|
||||
func TestLoad_EnvOverridesFile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
|
||||
// File enables with one proxy.
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_CA_PATH": ""
|
||||
}`), 0600)
|
||||
|
||||
// Env overrides: disable + different proxy (should be irrelevant once disabled).
|
||||
t.Setenv(envvars.CliProxyEnable, "false")
|
||||
t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:9999")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatalf("Load() = nil, want non-nil (file exists)")
|
||||
}
|
||||
if cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = true, want false (env override)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_ProxyURLMalformedDoesNotLeakUserinfo verifies that a malformed proxy
|
||||
// URL containing credentials does not leak those credentials in the error string.
|
||||
// url.Parse error strings embed the original URL, so wrapping them with %w would
|
||||
// expose user:password.
|
||||
func TestConfig_ProxyURLMalformedDoesNotLeakUserinfo(t *testing.T) {
|
||||
// Invalid percent-encoding in host makes url.Parse fail while userinfo is present.
|
||||
raw := "http://user:s3cret@%zz"
|
||||
_, err := (&Config{Proxy: raw}).proxyURL()
|
||||
if err == nil {
|
||||
t.Fatal("proxyURL() error = nil, want malformed URL error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "s3cret") {
|
||||
t.Fatalf("proxyURL() error leaks password: %q", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "user:") {
|
||||
t.Fatalf("proxyURL() error leaks username: %q", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "malformed URL") {
|
||||
t.Fatalf("proxyURL() error = %q, want it to mention malformed URL", err)
|
||||
}
|
||||
// The redacted form should still be present for diagnostics.
|
||||
if !strings.Contains(err.Error(), "***") {
|
||||
t.Fatalf("proxyURL() error = %q, want redacted userinfo marker", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resetLoadState resets the package-level Load() cache for deterministic tests.
|
||||
func resetLoadState() {
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
}
|
||||
|
||||
// TestLoad_RejectsWorldWritableConfig verifies that a world-writable proxy config
|
||||
// is rejected rather than silently trusted (it could be tampered with by other
|
||||
// local processes to redirect credential traffic).
|
||||
func TestLoad_RejectsWorldWritableConfig(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX permission semantics")
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
resetLoadState()
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
p := Path()
|
||||
writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
|
||||
// Chmod (not WriteFile perm) so umask cannot strip the world-writable bit.
|
||||
if err := os.Chmod(p, 0o666); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want unsafe-config error for world-writable file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "world-writable") {
|
||||
t.Fatalf("Load() error = %q, want world-writable rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_RejectsGroupWritableConfig verifies group-writable configs are rejected.
|
||||
func TestLoad_RejectsGroupWritableConfig(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("POSIX permission semantics")
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
resetLoadState()
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
p := Path()
|
||||
writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
|
||||
if err := os.Chmod(p, 0o660); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want unsafe-config error for group-writable file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "group-writable") {
|
||||
t.Fatalf("Load() error = %q, want group-writable rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_RejectsSymlinkConfig verifies that a symlinked proxy config is rejected,
|
||||
// preventing redirection of the trusted config path to an attacker-controlled file.
|
||||
func TestLoad_RejectsSymlinkConfig(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation is privileged on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
resetLoadState()
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
// Real file lives elsewhere; the config path is a symlink to it.
|
||||
real := filepath.Join(dir, "real_proxy_config.json")
|
||||
writeFile(t, real, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
|
||||
if err := os.Symlink(real, Path()); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want unsafe-config error for symlinked file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("Load() error = %q, want symlink rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_AcceptsSecureConfig verifies the audit does not break the normal case:
|
||||
// an owner-only 0600 config still loads.
|
||||
func TestLoad_AcceptsSecureConfig(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
resetLoadState()
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
writeFile(t, Path(), []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v, want nil for secure 0600 config", err)
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
|
||||
}
|
||||
}
|
||||
83
internal/transport/shared.go
Normal file
83
internal/transport/shared.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
//
|
||||
// Precedence (highest first):
|
||||
// 1. proxy-plugin mode — force traffic through a fixed loopback proxy;
|
||||
// FAIL-CLOSED when the plugin config exists but is invalid.
|
||||
// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled.
|
||||
// 3. http.DefaultTransport — the stdlib process-wide singleton (honors
|
||||
// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache.
|
||||
//
|
||||
// The returned RoundTripper MUST NOT be mutated. Callers that need a customized
|
||||
// transport should assert to *http.Transport and Clone() it. A shared base is
|
||||
// required so persistConn read/write goroutines are reused; cloning per call
|
||||
// leaks them until IdleConnTimeout (~90s) fires.
|
||||
func Shared() http.RoundTripper {
|
||||
// Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When
|
||||
// the plugin config exists but is invalid, pluginTransport returns a
|
||||
// fail-closed transport with ok=true and we return it here — we MUST NOT
|
||||
// fall through to the NO_PROXY / DefaultTransport direct-egress paths below.
|
||||
if t, ok := pluginTransport(); ok {
|
||||
return t
|
||||
}
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return noProxyTransport()
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared
|
||||
// retained so modules already on the leak-free singleton path (internal/auth,
|
||||
// internal/cmdutil transport decorators) do not have to migrate. New code
|
||||
// should prefer Shared and treat the base as an http.RoundTripper.
|
||||
//
|
||||
// Fail-closed invariant: pluginTransport always expresses its blocked transport
|
||||
// as a concrete *http.Transport (see failClosedTransport), so the assertion
|
||||
// below preserves the block. The noProxyTransport() fallback is therefore only
|
||||
// reached when no proxy plugin is configured and some external code replaced
|
||||
// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed
|
||||
// intent, where a proxy-disabled transport is acceptable.
|
||||
func Fallback() *http.Transport {
|
||||
if t, ok := Shared().(*http.Transport); ok {
|
||||
return t
|
||||
}
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
|
||||
// for outbound requests: a bare client falls back to http.DefaultTransport and
|
||||
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
|
||||
// fail-closed), creating an audit blind spot.
|
||||
//
|
||||
// A zero timeout means no client-level timeout (callers relying on context
|
||||
// deadlines pass 0).
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: Shared(),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
|
||||
// built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
t := def.Clone()
|
||||
t.Proxy = nil
|
||||
return t
|
||||
})
|
||||
156
internal/transport/shared_test.go
Normal file
156
internal/transport/shared_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
||||
func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
if Shared() != http.DefaultTransport {
|
||||
t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
||||
func TestShared_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
tr := Shared()
|
||||
if tr == http.DefaultTransport {
|
||||
t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
|
||||
}
|
||||
ht, ok := tr.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", tr)
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("no-proxy transport should have Proxy == nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
||||
func TestShared_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
if Shared() != Shared() {
|
||||
t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib
|
||||
// transport after unsetting LARK_CLI_NO_PROXY.
|
||||
func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
if Shared() == http.DefaultTransport {
|
||||
t.Fatal("precondition: first call with env set should not return DefaultTransport")
|
||||
}
|
||||
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
if after := Shared(); after != http.DefaultTransport {
|
||||
t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies.
|
||||
func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
ht, ok := Shared().(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", Shared())
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
||||
// transport (instead of a bare client that bypasses proxy plugin mode).
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
c := NewHTTPClient(7 * time.Second)
|
||||
if c.Transport == nil {
|
||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||
}
|
||||
if c.Transport != Shared() {
|
||||
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
|
||||
}
|
||||
if c.Timeout != 7*time.Second {
|
||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins
|
||||
// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy.
|
||||
func TestShared_PluginOverridesNoProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{
|
||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128"
|
||||
}`), 0600)
|
||||
|
||||
tr, ok := Shared().(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" {
|
||||
t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous
|
||||
// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never
|
||||
// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone.
|
||||
func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600) // malformed
|
||||
|
||||
rt := Shared()
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatal("malformed config returned http.DefaultTransport — fail OPEN")
|
||||
}
|
||||
if rt == noProxyTransport() {
|
||||
t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN")
|
||||
}
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
||||
}
|
||||
}
|
||||
68
internal/transport/tls_ca.go
Normal file
68
internal/transport/tls_ca.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// applyExtraRootCA augments t with an additional PEM bundle used for configured proxy
|
||||
// TLS interception.
|
||||
func applyExtraRootCA(t *http.Transport, caPath string) error {
|
||||
caPath = strings.TrimSpace(caPath)
|
||||
if caPath == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(caPath) {
|
||||
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliCAPath, caPath)
|
||||
}
|
||||
safeCAPath, err := binding.AssertSecurePath(binding.AuditParams{
|
||||
TargetPath: caPath,
|
||||
Label: envvars.CliCAPath,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unsafe %s %q: %w", envvars.CliCAPath, caPath, err)
|
||||
}
|
||||
pemBytes, err := vfs.ReadFile(safeCAPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s %q: %w", envvars.CliCAPath, caPath, err)
|
||||
}
|
||||
|
||||
// Augment the system trust store. Do NOT silently discard a SystemCertPool
|
||||
// error: falling back to an empty pool would make this transport trust ONLY
|
||||
// the extra CA (dropping all system roots), which narrows trust unexpectedly
|
||||
// and could break TLS to legitimate endpoints. Fail closed instead.
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load system cert pool for %s: %w", envvars.CliCAPath, err)
|
||||
}
|
||||
if pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
|
||||
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliCAPath, caPath)
|
||||
}
|
||||
|
||||
if t.TLSClientConfig == nil {
|
||||
t.TLSClientConfig = &tls.Config{}
|
||||
} else {
|
||||
// Clone to avoid mutating shared config from the base transport.
|
||||
t.TLSClientConfig = t.TLSClientConfig.Clone()
|
||||
}
|
||||
if t.TLSClientConfig.MinVersion == 0 || t.TLSClientConfig.MinVersion < tls.VersionTLS12 {
|
||||
t.TLSClientConfig.MinVersion = tls.VersionTLS12
|
||||
}
|
||||
t.TLSClientConfig.RootCAs = pool
|
||||
return nil
|
||||
}
|
||||
173
internal/transport/tls_ca_test.go
Normal file
173
internal/transport/tls_ca_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
|
||||
func mustCreateTestCertPEM(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey() error = %v", err)
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "proxyplugin-test-ca",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "proxyplugin-test-ca",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate() error = %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
|
||||
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
if err := applyExtraRootCA(tr, " "); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig != nil {
|
||||
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
|
||||
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
err := applyExtraRootCA(tr, "ca.pem")
|
||||
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsMissingFile verifies missing PEM bundles fail before file reads.
|
||||
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
|
||||
tr := &http.Transport{}
|
||||
|
||||
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
|
||||
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "invalid.pem")
|
||||
writeFile(t, caPath, []byte("not a pem"), 0600)
|
||||
|
||||
tr := &http.Transport{}
|
||||
err := applyExtraRootCA(tr, caPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_RejectsInsecureCAPath verifies CA paths are safety-checked
|
||||
// before reading the configured file.
|
||||
func TestApplyExtraRootCA_RejectsInsecureCAPath(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
if err := os.Chmod(caPath, 0666); err != nil {
|
||||
t.Fatalf("Chmod() error = %v", err)
|
||||
}
|
||||
|
||||
tr := &http.Transport{}
|
||||
err := applyExtraRootCA(tr, caPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe") {
|
||||
t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err)
|
||||
}
|
||||
if tr.TLSClientConfig != nil {
|
||||
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
|
||||
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
tr := &http.Transport{}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil, want initialized config")
|
||||
}
|
||||
if tr.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("RootCAs = nil, want cert pool")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
|
||||
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
original := &tls.Config{ServerName: "open.feishu.cn"}
|
||||
tr := &http.Transport{TLSClientConfig: original}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig == original {
|
||||
t.Fatal("TLSClientConfig pointer reused, want clone")
|
||||
}
|
||||
if tr.TLSClientConfig.ServerName != original.ServerName {
|
||||
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
|
||||
}
|
||||
if tr.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("RootCAs = nil, want cert pool")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyExtraRootCA_PreservesHigherTLSMinVersion verifies that adding a CA
|
||||
// does not relax an existing stricter TLS version floor.
|
||||
func TestApplyExtraRootCA_PreservesHigherTLSMinVersion(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "ca.pem")
|
||||
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
|
||||
|
||||
tr := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}}
|
||||
if err := applyExtraRootCA(tr, caPath); err != nil {
|
||||
t.Fatalf("applyExtraRootCA() error = %v", err)
|
||||
}
|
||||
if tr.TLSClientConfig.MinVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("MinVersion = %x, want %x", tr.TLSClientConfig.MinVersion, tls.VersionTLS13)
|
||||
}
|
||||
}
|
||||
90
internal/transport/transport.go
Normal file
90
internal/transport/transport.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// proxyPluginTransport is a fixed-proxy clone of http.DefaultTransport (with optional
|
||||
// custom root CA), lazily built on first use when proxy plugin mode is enabled.
|
||||
var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
|
||||
|
||||
// cachedBlockedTransport is a fail-closed transport cached on first use when
|
||||
// the proxy plugin config exists but is invalid. This avoids cloning
|
||||
// http.DefaultTransport on every pluginTransport call.
|
||||
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||
|
||||
func buildBlockedTransport() http.RoundTripper {
|
||||
return failClosedTransport(fmt.Errorf("proxy plugin config is invalid: %w", loadErr))
|
||||
}
|
||||
|
||||
func buildProxyPluginTransport() http.RoundTripper {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Cannot clone the stdlib transport. Fail closed with a concrete
|
||||
// *http.Transport (not a bare RoundTripper) so downcasting callers such
|
||||
// as Fallback cannot silently degrade this into a
|
||||
// direct-egress transport.
|
||||
return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport))
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
// Fail closed: config file exists but is malformed/unreadable — do not
|
||||
// silently fall back to direct egress.
|
||||
return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err))
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
return def
|
||||
}
|
||||
t, err := cfg.ApplyToTransport(def)
|
||||
if err != nil {
|
||||
// Fail closed: do not silently fall back to direct egress when the
|
||||
// operator explicitly enabled proxy plugin mode.
|
||||
return blockedTransport(def, fmt.Errorf("proxy plugin enabled but config is invalid: %w", err))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// pluginTransport returns the proxy plugin transport when proxy plugin mode is
|
||||
// configured. The bool return is false when the plugin is not configured or not enabled.
|
||||
func pluginTransport() (http.RoundTripper, bool) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
return cachedBlockedTransport(), true
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
return proxyPluginTransport(), true
|
||||
}
|
||||
|
||||
// failClosedTransport returns a *http.Transport that always fails RoundTrip with
|
||||
// err. It clones http.DefaultTransport when possible (preserving dial/timeout
|
||||
// tuning); otherwise it builds a minimal transport. Returning a concrete
|
||||
// *http.Transport (rather than a bare RoundTripper) is required so downcasting
|
||||
// callers such as Fallback cannot silently degrade a fail-closed
|
||||
// signal into a direct-egress transport.
|
||||
func failClosedTransport(err error) *http.Transport {
|
||||
if def, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
return blockedTransport(def, err)
|
||||
}
|
||||
return &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func blockedTransport(base *http.Transport, err error) *http.Transport {
|
||||
blocked := base.Clone()
|
||||
blocked.Proxy = func(*http.Request) (*url.URL, error) {
|
||||
return nil, err
|
||||
}
|
||||
return blocked
|
||||
}
|
||||
195
internal/transport/transport_test.go
Normal file
195
internal/transport/transport_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resetProxyPluginState() {
|
||||
loadOnce = sync.Once{}
|
||||
loadCfg = nil
|
||||
loadErr = nil
|
||||
proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport)
|
||||
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||
}
|
||||
|
||||
func TestPluginTransport_NotConfigured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
tr, ok := pluginTransport()
|
||||
if ok {
|
||||
t.Fatalf("pluginTransport() ok = true, want false")
|
||||
}
|
||||
if tr != nil {
|
||||
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
cfgPath := Path()
|
||||
writeFile(t, cfgPath, []byte(`{
|
||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128",
|
||||
"LARKSUITE_CLI_CA_PATH": ""
|
||||
}`), 0600)
|
||||
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
tr, ok := rt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("pluginTransport() = %T, want *http.Transport", rt)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Proxy() error = %v", err)
|
||||
}
|
||||
if u == nil || u.String() != "http://127.0.0.1:3128" {
|
||||
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{})
|
||||
defer restoreDefaultTransport()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||
}
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
a, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
b, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyPluginTransport_InvalidConfigFailsClosed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt := buildProxyPluginTransport()
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||
}
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{})
|
||||
defer restoreDefaultTransport()
|
||||
|
||||
rt := buildProxyPluginTransport()
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||
}
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the
|
||||
// fail-closed invariant that Fallback relies on: even when
|
||||
// http.DefaultTransport is not an *http.Transport, an invalid proxy config must
|
||||
// produce a blocked transport that is itself a concrete *http.Transport. If it
|
||||
// were a bare RoundTripper, Fallback would downcast-fail and
|
||||
// silently degrade it into a direct-egress transport.
|
||||
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{})
|
||||
defer restoreDefaultTransport()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if _, isTransport := rt.(*http.Transport); !isTransport {
|
||||
t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt)
|
||||
}
|
||||
// Must remain fail-closed.
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp)
|
||||
}
|
||||
if resp != nil {
|
||||
t.Fatalf("RoundTrip() response = %#v, want nil", resp)
|
||||
}
|
||||
}
|
||||
|
||||
type okRoundTripper struct{}
|
||||
|
||||
func (okRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil
|
||||
}
|
||||
|
||||
func replaceDefaultTransport(rt http.RoundTripper) func() {
|
||||
original := http.DefaultTransport
|
||||
http.DefaultTransport = rt
|
||||
return func() {
|
||||
http.DefaultTransport = original
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Proxy environment constants control shared transport proxy behavior.
|
||||
const (
|
||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||
@@ -36,8 +38,21 @@ func DetectProxyEnv() (key, value string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
|
||||
var proxyWarningOnce sync.Once
|
||||
|
||||
// proxyPluginStatus reports the configured proxy plugin address, the extra
|
||||
// trusted CA path (if any), and whether proxy plugin mode is enabled. It is
|
||||
// indirected through a package variable so tests can simulate plugin-enabled
|
||||
// mode without the process-global Load() sync.Once cache.
|
||||
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
|
||||
cfg, err := Load()
|
||||
if err != nil || !cfg.Enabled() {
|
||||
return "", "", false
|
||||
}
|
||||
return cfg.Proxy, cfg.CAPath, true
|
||||
}
|
||||
|
||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||
// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats.
|
||||
func redactProxyURL(raw string) string {
|
||||
@@ -60,6 +75,22 @@ func redactProxyURL(raw string) string {
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
func WarnIfProxied(w io.Writer) {
|
||||
proxyWarningOnce.Do(func() {
|
||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||
// Shared), so its warning and disable instructions take precedence.
|
||||
// Emitting the env-proxy warning here would be misleading: it tells the
|
||||
// user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy.
|
||||
if addr, caPath, enabled := proxyPluginStatus(); enabled {
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n",
|
||||
redactProxyURL(addr), envvars.CliProxyEnable, Path())
|
||||
if strings.TrimSpace(caPath) != "" {
|
||||
// A custom CA means upstream TLS can be intercepted/inspected by
|
||||
// the proxy (MITM). Surface it so the operator is aware traffic
|
||||
// (including Bearer tokens) is decryptable on this host.
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n",
|
||||
caPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return
|
||||
}
|
||||
@@ -71,48 +102,3 @@ func WarnIfProxied(w io.Writer) {
|
||||
key, redactProxyURL(val), EnvNoProxy)
|
||||
})
|
||||
}
|
||||
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport,
|
||||
// lazily built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
t := def.Clone()
|
||||
t.Proxy = nil
|
||||
return t
|
||||
})
|
||||
|
||||
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
|
||||
//
|
||||
// By default it returns http.DefaultTransport — the stdlib-provided
|
||||
// process-wide singleton — so every HTTP client in the process shares one
|
||||
// TCP connection pool, TLS session cache, and HTTP/2 state. When
|
||||
// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton
|
||||
// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built
|
||||
// at most once.
|
||||
//
|
||||
// The returned RoundTripper MUST NOT be mutated. Callers that need a
|
||||
// customized transport should assert to *http.Transport and Clone() it.
|
||||
// Using a shared base is required so persistConn readLoop/writeLoop
|
||||
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
|
||||
// (~90s) fires.
|
||||
func SharedTransport() http.RoundTripper {
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return noProxyTransport()
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// FallbackTransport returns a shared *http.Transport singleton. It is a
|
||||
// thin wrapper over SharedTransport retained so modules that were already
|
||||
// on the leak-free singleton path (internal/auth, internal/cmdutil
|
||||
// transport decorators) do not have to migrate. New code should prefer
|
||||
// SharedTransport and treat the base as an http.RoundTripper.
|
||||
func FallbackTransport() *http.Transport {
|
||||
if t, ok := SharedTransport().(*http.Transport); ok {
|
||||
return t
|
||||
}
|
||||
return noProxyTransport()
|
||||
}
|
||||
258
internal/transport/warn_test.go
Normal file
258
internal/transport/warn_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
||||
func TestDetectProxyEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
|
||||
// Clear all proxy env vars first
|
||||
for _, k := range proxyEnvKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
key, val := DetectProxyEnv()
|
||||
if key != "" || val != "" {
|
||||
t.Errorf("expected no proxy, got %s=%s", key, val)
|
||||
}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8888")
|
||||
key, val = DetectProxyEnv()
|
||||
if key != "HTTPS_PROXY" || val != "http://proxy:8888" {
|
||||
t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
|
||||
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
t.Error("expected warning output when proxy is set")
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) {
|
||||
t.Errorf("warning should mention HTTPS_PROXY, got: %s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) {
|
||||
t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
|
||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
for _, k := range proxyEnvKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings.
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
first := buf.String()
|
||||
|
||||
WarnIfProxied(&buf)
|
||||
second := buf.String()
|
||||
|
||||
if first == "" {
|
||||
t.Error("expected warning on first call")
|
||||
}
|
||||
if second != first {
|
||||
t.Error("expected no additional output on second call")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is
|
||||
// enabled, the warning describes the plugin proxy and the correct disable method
|
||||
// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY
|
||||
// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set.
|
||||
func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
old := proxyPluginStatus
|
||||
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
// Plugin mode overrides these; the warning must still be the plugin one.
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:8080")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "127.0.0.1:3128") {
|
||||
t.Errorf("warning should mention the plugin proxy address, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, envvars.CliProxyEnable) {
|
||||
t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out)
|
||||
}
|
||||
if strings.Contains(out, "Set "+EnvNoProxy+"=1") {
|
||||
t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out)
|
||||
}
|
||||
// No custom CA configured -> no interception warning.
|
||||
if strings.Contains(out, "custom CA") {
|
||||
t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
|
||||
// trusted, the warning surfaces the TLS-interception capability.
|
||||
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
old := proxyPluginStatus
|
||||
proxyPluginStatus = func() (string, string, bool) {
|
||||
return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true
|
||||
}
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "custom CA") {
|
||||
t.Errorf("warning should mention the custom CA, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/etc/lark/extra_ca.pem") {
|
||||
t.Errorf("warning should include the CA path, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "intercept") {
|
||||
t.Errorf("warning should mention TLS interception, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
|
||||
// warning never leaks credentials embedded in the configured proxy address.
|
||||
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
old := proxyPluginStatus
|
||||
proxyPluginStatus = func() (string, string, bool) { return "http://user:s3cret@127.0.0.1:3128", "", true }
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, "s3cret") {
|
||||
t.Errorf("plugin warning leaked password, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "user:") {
|
||||
t.Errorf("plugin warning leaked username, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "***@127.0.0.1:3128") {
|
||||
t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||
func TestRedactProxyURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"http://proxy:8080", "http://proxy:8080"},
|
||||
{"http://user:pass@proxy:8080", "http://***@proxy:8080/"},
|
||||
{"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"},
|
||||
{"http://user@proxy:8080", "http://***@proxy:8080/"},
|
||||
{"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"},
|
||||
{"user:pass@proxy:8080", "***@proxy:8080"},
|
||||
{"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"},
|
||||
{"not-a-url", "not-a-url"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := redactProxyURL(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
|
||||
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
||||
t.Errorf("warning should not contain proxy password, got: %s", out)
|
||||
}
|
||||
if bytes.Contains([]byte(out), []byte("admin")) {
|
||||
t.Errorf("warning should not contain proxy username, got: %s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) {
|
||||
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: util.SharedTransport(),
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectProxyEnv(t *testing.T) {
|
||||
// Clear all proxy env vars first
|
||||
for _, k := range proxyEnvKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
key, val := DetectProxyEnv()
|
||||
if key != "" || val != "" {
|
||||
t.Errorf("expected no proxy, got %s=%s", key, val)
|
||||
}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8888")
|
||||
key, val = DetectProxyEnv()
|
||||
if key != "HTTPS_PROXY" || val != "http://proxy:8888" {
|
||||
t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := SharedTransport()
|
||||
if tr != http.DefaultTransport {
|
||||
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
tr := SharedTransport()
|
||||
if tr == http.DefaultTransport {
|
||||
t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
|
||||
}
|
||||
ht, ok := tr.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", tr)
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("no-proxy transport should have Proxy == nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
a := SharedTransport()
|
||||
b := SharedTransport()
|
||||
if a != b {
|
||||
t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
noProxy := SharedTransport()
|
||||
if noProxy == http.DefaultTransport {
|
||||
t.Fatal("precondition: first call with env set should not return DefaultTransport")
|
||||
}
|
||||
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
after := SharedTransport()
|
||||
if after != http.DefaultTransport {
|
||||
t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
ht, ok := SharedTransport().(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", SharedTransport())
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
// Reset the once guard for this test
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if out == "" {
|
||||
t.Error("expected warning output when proxy is set")
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) {
|
||||
t.Errorf("warning should mention HTTPS_PROXY, got: %s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) {
|
||||
t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
for _, k := range proxyEnvKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output when no proxy is set, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTP_PROXY", "http://proxy:1234")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
first := buf.String()
|
||||
|
||||
WarnIfProxied(&buf)
|
||||
second := buf.String()
|
||||
|
||||
if first == "" {
|
||||
t.Error("expected warning on first call")
|
||||
}
|
||||
if second != first {
|
||||
t.Error("expected no additional output on second call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactProxyURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"http://proxy:8080", "http://proxy:8080"},
|
||||
{"http://user:pass@proxy:8080", "http://***@proxy:8080/"},
|
||||
{"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"},
|
||||
{"http://user@proxy:8080", "http://***@proxy:8080/"},
|
||||
{"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"},
|
||||
{"user:pass@proxy:8080", "***@proxy:8080"},
|
||||
{"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"},
|
||||
{"not-a-url", "not-a-url"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := redactProxyURL(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
out := buf.String()
|
||||
if bytes.Contains([]byte(out), []byte("s3cret")) {
|
||||
t.Errorf("warning should not contain proxy password, got: %s", out)
|
||||
}
|
||||
if bytes.Contains([]byte(out), []byte("admin")) {
|
||||
t.Errorf("warning should not contain proxy username, got: %s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) {
|
||||
t.Errorf("warning should contain redacted proxy URL, got: %s", out)
|
||||
}
|
||||
}
|
||||
146
lint/errscontract/rule_no_legacy_envelope_literal.go
Normal file
146
lint/errscontract/rule_no_legacy_envelope_literal.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
|
||||
// to the typed errs.* taxonomy. On these paths, constructing a legacy
|
||||
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/drive/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
|
||||
// name (default or alias) this path is bound to in each file, so an aliased
|
||||
// import cannot bypass the check.
|
||||
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
|
||||
|
||||
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
|
||||
// output.ExitError / output.ErrDetail composite literals on migrated paths.
|
||||
// forbidigo can ban identifiers but not composite literals, so this AST rule
|
||||
// covers the gap left after a path is migrated to typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
|
||||
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
|
||||
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
|
||||
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Resolve the local name(s) bound to the legacy output import path. A file
|
||||
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
|
||||
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
|
||||
// as bare unqualified idents.
|
||||
localNames, dotImported := resolveLegacyOutputNames(file)
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_envelope_literal",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(lit.Pos()).Line,
|
||||
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
|
||||
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
|
||||
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// isMigratedEnvelopePath reports whether path falls under any migrated path
|
||||
// prefix in migratedEnvelopePaths.
|
||||
func isMigratedEnvelopePath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedEnvelopePaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveLegacyOutputNames walks the file's import declarations and returns the
|
||||
// set of local names bound to legacyOutputImportPath, plus whether the path was
|
||||
// dot-imported. Default imports bind the package's own name ("output"); aliased
|
||||
// imports bind the alias; dot-imports bind names into the file scope.
|
||||
func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != legacyOutputImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
// Default import: local name is the package name "output".
|
||||
names["output"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
// Blank import cannot reference the types; ignore.
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
|
||||
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
|
||||
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
|
||||
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
|
||||
// local names for the legacy output import, and — when the package was
|
||||
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
|
||||
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
|
||||
if sel, ok := expr.(*ast.SelectorExpr); ok {
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok || sel.Sel == nil {
|
||||
return "", false
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return "", false
|
||||
}
|
||||
return matchLegacyEnvelopeName(sel.Sel.Name)
|
||||
}
|
||||
if dotImported {
|
||||
if ident, ok := expr.(*ast.Ident); ok {
|
||||
return matchLegacyEnvelopeName(ident.Name)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// matchLegacyEnvelopeName returns the name when it is one of the legacy
|
||||
// envelope type names.
|
||||
func matchLegacyEnvelopeName(name string) (string, bool) {
|
||||
switch name {
|
||||
case "ExitError", "ErrDetail":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
73
lint/errscontract/rule_no_legacy_runtime_api_call.go
Normal file
73
lint/errscontract/rule_no_legacy_runtime_api_call.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy
|
||||
// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on
|
||||
// migrated paths. Those helpers route failures through common.HandleApiResult /
|
||||
// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and
|
||||
// downgrade an already-typed network / auth boundary error into an API error.
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper
|
||||
// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel == nil {
|
||||
return true
|
||||
}
|
||||
if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_runtime_api_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's
|
||||
// legacy auto-classifying API helper methods.
|
||||
func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
switch name {
|
||||
case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -593,3 +593,287 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
|
||||
t.Errorf("message must name the offending call: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
|
||||
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() *output.ErrDetail {
|
||||
return &output.ErrDetail{Code: 7}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ErrDetail") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
|
||||
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
|
||||
// Same offending literal, but outside the migrated path set → not flagged.
|
||||
src := `package other
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("_test.go file should be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
|
||||
// import of internal/output cannot bypass the rule: the qualifier is resolved
|
||||
// from the import declaration, not matched against the literal string "output".
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &legacy.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
|
||||
// regression where resolving by import path accidentally drops the default
|
||||
// (non-aliased) `output` case.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
|
||||
// a CallExpr, not a composite literal — even under an alias it must not fire.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return legacy.ErrBare(legacy.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
|
||||
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
|
||||
// the composite literal.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
|
||||
// selector on an unrelated package (not the legacy output import path) must not
|
||||
// trigger a false positive.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "example.com/other/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("unrelated package selector must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
|
||||
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed wrapper call must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
|
||||
// RawAPI / DoAPI return the raw response for the caller to classify and do
|
||||
// not emit a legacy envelope — they are not banned.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
|
||||
_, err := runtime.DoAPI(nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("test files must be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckNoRegistrar(rel, string(src))...)
|
||||
all = append(all, CheckAdHocSubtype(rel, string(src))...)
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
|
||||
// Typed-error invariants — self-scope to errs/ + classify.go.
|
||||
all = append(all, CheckNilSafeError(rel, string(src))...)
|
||||
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.44",
|
||||
"version": "1.0.46",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -25,6 +25,10 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
"Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Caller must be a Base admin; enable advanced permissions before creating or updating roles.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -24,6 +24,11 @@ var BaseBaseCopy = common.Shortcut{
|
||||
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
|
||||
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +base-copy --base-token <base_token> --name "Copy of Project Tracker"`,
|
||||
"Use --without-content when the user wants only structure.",
|
||||
"If copied as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
|
||||
},
|
||||
DryRun: dryRunBaseCopy,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseCopy(runtime)
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{
|
||||
{Name: "folder-token", Desc: "folder token for destination"},
|
||||
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`,
|
||||
"If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
|
||||
},
|
||||
DryRun: dryRunBaseCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseCreate(runtime)
|
||||
|
||||
@@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true},
|
||||
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use +data-query for server-side aggregation, grouping, filtering, sorting, and Top N queries.",
|
||||
"Read lark-base-data-query-guide.md for common fewshots; use lark-base-data-query.md only when the full DSL reference is needed.",
|
||||
"`dimensions` and `measures` cannot both be empty.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
var dsl map[string]interface{}
|
||||
|
||||
@@ -71,6 +71,29 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
filteredListRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"sort-json": `[{"field":"Due","desc":true}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordList(ctx, filteredListRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records",
|
||||
"limit=20",
|
||||
"filter=%7B",
|
||||
"Status",
|
||||
"Todo",
|
||||
"sort=%5B",
|
||||
"Due",
|
||||
)
|
||||
|
||||
commaFieldRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-id": {"A,B", "C"}},
|
||||
@@ -99,6 +122,33 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
`"limit":500`,
|
||||
)
|
||||
|
||||
searchFlagRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"keyword": "Alice",
|
||||
"view-id": "viw_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`,
|
||||
"sort-json": `[{"field":"Updated At","desc":true}]`,
|
||||
},
|
||||
map[string][]string{
|
||||
"search-field": {"Name"},
|
||||
"field-id": {"Name", "Status"},
|
||||
},
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordSearch(ctx, searchFlagRT),
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
|
||||
`"keyword":"Alice"`,
|
||||
`"search_fields":["Name"]`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`,
|
||||
`"sort":[{"desc":true,"field":"Updated At"}]`,
|
||||
)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
|
||||
@@ -515,7 +515,7 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
if !strings.Contains(err.Error(), "match the documented shape") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
@@ -974,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
@@ -990,12 +990,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(body, `"keyword":"Created"`) ||
|
||||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
|
||||
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
|
||||
!strings.Contains(body, `"offset":0`) ||
|
||||
!strings.Contains(body, `"limit":2`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with flag filter sort and projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Status"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_status"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "Created",
|
||||
"--search-field", "Title",
|
||||
"--field-id", "Title",
|
||||
"--field-id", "Status",
|
||||
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
|
||||
"--limit", "20",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
|
||||
}
|
||||
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
|
||||
t.Fatalf("captured body=%#v", body)
|
||||
}
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["logic"] != "and" {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
conditions := filter["conditions"].([]interface{})
|
||||
if len(conditions) != 2 {
|
||||
t.Fatalf("conditions=%#v", conditions)
|
||||
}
|
||||
sortConfig := body["sort"].([]interface{})
|
||||
if len(sortConfig) != 2 {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with filter json file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
tmp := t.TempDir()
|
||||
withBaseWorkingDir(t, tmp)
|
||||
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
|
||||
t.Fatalf("write filter err=%v", err)
|
||||
}
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "A",
|
||||
"--search-field", "Title",
|
||||
"--filter-json", "@filter.json",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
body := string(searchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{
|
||||
{Name: "name", Desc: "form name", Required: true},
|
||||
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`},
|
||||
},
|
||||
Tips: []string{
|
||||
"Record the returned form_id; form question create/list/update/delete commands need it.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseFormDelete = common.Shortcut{
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use +form-list or +form-get first when the form target is ambiguous.",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id").
|
||||
|
||||
@@ -23,7 +23,7 @@ var BaseFormsList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormQuestionsList = common.Shortcut{
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use returned question id values for +form-questions-update and +form-questions-delete.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
|
||||
@@ -17,7 +17,10 @@ var BaseBaseGet = common.Shortcut{
|
||||
Scopes: []string{"base:app:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true)},
|
||||
DryRun: dryRunBaseGet,
|
||||
Tips: []string{
|
||||
"Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.",
|
||||
},
|
||||
DryRun: dryRunBaseGet,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseGet(runtime)
|
||||
},
|
||||
|
||||
@@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
{Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
|
||||
"Create supports custom_role only.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -26,6 +26,12 @@ var BaseRoleDelete = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Only custom roles can be deleted; system roles cannot be deleted.",
|
||||
"Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -27,6 +27,10 @@ var BaseRoleGet = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Use before +role-update to inspect the current full permission config.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Returns role summaries; use +role-get for the full permission config.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
{Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Update is a delta merge: only changed fields are updated, others remain unchanged.",
|
||||
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -198,6 +198,25 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Risk != "high-risk-write" {
|
||||
continue
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
flag := cmd.Flags().Lookup("yes")
|
||||
if flag == nil {
|
||||
t.Fatalf("%s missing --yes flag", shortcut.Command)
|
||||
}
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
if !strings.Contains(tips, "pass --yes without asking again") {
|
||||
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -235,36 +254,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Text equality filter",
|
||||
"Option intersection filter",
|
||||
"Query priority",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
|
||||
"keyword for record search",
|
||||
"field ID or name to search",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Example: lark-cli base +record-search",
|
||||
"Example with filter/sort JSON",
|
||||
"Text equality filter",
|
||||
"Query priority",
|
||||
"Use --json only when you need to pass the full search body directly",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -311,6 +333,401 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "dashboard list",
|
||||
shortcut: BaseDashboardList,
|
||||
wantTips: []string{
|
||||
"Use returned dashboard_id values",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard get",
|
||||
shortcut: BaseDashboardGet,
|
||||
wantTips: []string{
|
||||
"block-level details",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard create",
|
||||
shortcut: BaseDashboardCreate,
|
||||
wantTips: []string{
|
||||
"Record the returned dashboard_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard update",
|
||||
shortcut: BaseDashboardUpdate,
|
||||
wantTips: []string{},
|
||||
},
|
||||
{
|
||||
name: "dashboard delete",
|
||||
shortcut: BaseDashboardDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
|
||||
"also deletes its blocks",
|
||||
"pass --yes",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard arrange",
|
||||
shortcut: BaseDashboardArrange,
|
||||
wantTips: []string{
|
||||
"not deterministic or position-specific",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block list",
|
||||
shortcut: BaseDashboardBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
|
||||
"Use returned block_id and type values",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block get",
|
||||
shortcut: BaseDashboardBlockGet,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
|
||||
"metadata such as name, type, layout, and data_config",
|
||||
"computed chart result",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block get data",
|
||||
shortcut: BaseDashboardBlockGetData,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
||||
"does not need --dashboard-id",
|
||||
"computed chart protocol JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block create",
|
||||
shortcut: BaseDashboardBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
||||
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
|
||||
"+table-list and +field-list",
|
||||
"not table_id or field_id",
|
||||
"dashboard-block-data-config.md as the SSOT",
|
||||
"do not invent data_config from natural language",
|
||||
"sequentially",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block update",
|
||||
shortcut: BaseDashboardBlockUpdate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
||||
`--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
|
||||
"dashboard-block-data-config.md as the SSOT",
|
||||
"do not invent data_config from natural language",
|
||||
"Block type cannot be changed",
|
||||
"top-level keys",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block delete",
|
||||
shortcut: BaseDashboardBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
|
||||
"pass --yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "workflow list",
|
||||
shortcut: BaseWorkflowList,
|
||||
wantTips: []string{
|
||||
"workflow_id values with wkf prefix",
|
||||
"auto-paginates",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow get",
|
||||
shortcut: BaseWorkflowGet,
|
||||
wantTips: []string{
|
||||
"workflow-id must start with wkf",
|
||||
"steps may be an empty array",
|
||||
"Use +workflow-get before +workflow-update",
|
||||
"lark-base-workflow-schema.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow create",
|
||||
shortcut: BaseWorkflowCreate,
|
||||
wantTips: []string{
|
||||
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
|
||||
"client_token is required",
|
||||
"New workflows are created disabled",
|
||||
"+table-list and +field-list",
|
||||
"Step ids must be unique",
|
||||
"lark-base-workflow-guide.md as the entry guide",
|
||||
"lark-base-workflow-schema.md as the steps JSON SSOT",
|
||||
"do not invent steps[].type/data/next/children from natural language",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow update",
|
||||
shortcut: BaseWorkflowUpdate,
|
||||
wantTips: []string{
|
||||
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
|
||||
"PUT uses full replacement semantics",
|
||||
"Use +workflow-get first",
|
||||
"keep title/status/steps fields",
|
||||
"workflow-id must start with wkf",
|
||||
"Updating does not enable or disable",
|
||||
"do not invent steps[].type/data/next/children from natural language",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow enable",
|
||||
shortcut: BaseWorkflowEnable,
|
||||
wantTips: []string{
|
||||
"workflow-id must start with wkf",
|
||||
"does not modify steps",
|
||||
"New workflows are created disabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow disable",
|
||||
shortcut: BaseWorkflowDisable,
|
||||
wantTips: []string{
|
||||
"workflow-id must start with wkf",
|
||||
"does not delete the workflow or its steps",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
}{
|
||||
{
|
||||
name: "table create fields",
|
||||
shortcut: BaseTableCreate,
|
||||
wantHelp: []string{
|
||||
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
wantHelp: []string{
|
||||
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set sort",
|
||||
shortcut: BaseViewSetSort,
|
||||
wantHelp: []string{
|
||||
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
|
||||
`use {"sort_config":[]} to clear`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set group",
|
||||
shortcut: BaseViewSetGroup,
|
||||
wantHelp: []string{
|
||||
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
wantHelp: []string{
|
||||
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
wantHelp: []string{
|
||||
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
wantHelp: []string{
|
||||
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question delete",
|
||||
shortcut: BaseFormQuestionsDelete,
|
||||
wantHelp: []string{
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
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`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record upsert json",
|
||||
shortcut: BaseRecordUpsert,
|
||||
wantHelp: []string{
|
||||
`record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch create json",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantHelp: []string{
|
||||
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch update json",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantHelp: []string{
|
||||
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
wantTips: []string{
|
||||
"Happy path JSON is a top-level field map",
|
||||
"Without --record-id this creates a record",
|
||||
"does not auto-upsert by business key",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"do not write system fields, formula, lookup, or attachment fields",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
"datetime -> \"2026-03-24 10:00:00\"",
|
||||
"checkbox -> true/false",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
`location uses {"lng":116.397428,"lat":39.90923}`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates",
|
||||
"lark-base-cell-value.md",
|
||||
"do not invent values for fields not covered by the happy path",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: fields is the column order",
|
||||
"rows is an array of row arrays",
|
||||
"may use null for empty cells",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch create supports max 200 rows per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
"lark-base-cell-value.md",
|
||||
"do not invent values for fields not covered by the happy path",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: record_id_list is the target record IDs",
|
||||
"patch is a field map applied unchanged to every target record",
|
||||
"Do not use +record-batch-update for per-row different values",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch update supports max 200 records per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
"lark-base-cell-value.md",
|
||||
"do not invent values for fields not covered by the happy path",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -328,7 +745,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
@@ -472,11 +889,11 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
if BaseRecordList.Validate == nil {
|
||||
t.Fatalf("record list validate should reject invalid query flags before dry-run")
|
||||
}
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
@@ -487,6 +904,58 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("record upsert map validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record list filter-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search flag validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
"sort-json": `[{"field":"Title","desc":false}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
|
||||
},
|
||||
DryRun: dryRunDashboardArrange,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeDashboardArrange(runtime)
|
||||
|
||||
@@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`,
|
||||
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
|
||||
"data_config uses table and field names, not table_id or field_id.",
|
||||
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
||||
"Record the returned block_id; block update/delete/get-data commands need it.",
|
||||
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
if runtime.Bool("no-validate") {
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").
|
||||
|
||||
@@ -24,6 +24,11 @@ var BaseDashboardBlockGet = common.Shortcut{
|
||||
blockIDFlag(true),
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
|
||||
"Use this command for block metadata such as name, type, layout, and data_config.",
|
||||
"Use +dashboard-block-get-data when you need the computed chart result instead of metadata.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {
|
||||
|
||||
@@ -23,6 +23,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
||||
"This command does not need --dashboard-id.",
|
||||
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
|
||||
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
|
||||
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
|
||||
|
||||
@@ -21,9 +21,13 @@ var BaseDashboardBlockList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "page-size", Desc: "page size (max 100)"},
|
||||
{Name: "page-size", Desc: "page size, default 20, max 100"},
|
||||
{Name: "page-token", Desc: "pagination token"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
|
||||
"Use returned block_id and type values for +dashboard-block-get/update/delete/get-data.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||
|
||||
@@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
{Name: "name", Desc: "new block name"},
|
||||
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
|
||||
"Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
||||
"Use +dashboard-block-get first to inspect the current data_config before replacing nested values.",
|
||||
"Block type cannot be changed; delete and recreate the block to change chart type.",
|
||||
"data_config update merges top-level keys, but each provided key is replaced as a whole.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
if runtime.Bool("no-validate") {
|
||||
|
||||
@@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "name", Desc: "dashboard name", Required: true},
|
||||
{Name: "theme-style", Desc: "theme style"},
|
||||
{Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Record the returned dashboard_id; dashboard block create/get/update/delete/arrange commands need it.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
|
||||
"Deleting a dashboard also deletes its blocks and cannot be recovered.",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||
|
||||
@@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Use +dashboard-block-list or +dashboard-block-get when you need block-level details.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||
|
||||
@@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "page-size", Desc: "page size (max 100)"},
|
||||
{Name: "page-size", Desc: "page size, max 100"},
|
||||
{Name: "page-token", Desc: "pagination token"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use returned dashboard_id values for +dashboard-get, +dashboard-block-list, and +dashboard-block-create.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||
|
||||
@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "new dashboard name"},
|
||||
{Name: "theme-style", Desc: "theme style"},
|
||||
{Name: "theme-style", Desc: "theme style, leave empty to keep current theme"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -23,7 +23,8 @@ var BaseFieldCreate = common.Shortcut{
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{
|
||||
Scopes: []string{"base:field:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||
DryRun: dryRunFieldDelete,
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
`Example: lark-cli base +field-delete --base-token <base_token> --table-id <table_id> --field-id "Status" --yes`,
|
||||
},
|
||||
DryRun: dryRunFieldDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFieldDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{
|
||||
Scopes: []string{"base:field:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||
DryRun: dryRunFieldGet,
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-get --base-token <base_token> --table-id <table_id> --field-id "Status"`,
|
||||
"field-id accepts a field ID (fld...) or the field name from the current table.",
|
||||
"Returns full field configuration; use it as the baseline before +field-update.",
|
||||
},
|
||||
DryRun: dryRunFieldGet,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFieldGet(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
DryRun: dryRunFieldList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
fieldRefFlag(true),
|
||||
{Name: "keyword", Desc: "keyword for option query"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for fields with options, such as select or multi-select fields.",
|
||||
},
|
||||
DryRun: dryRunFieldSearchOptions,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -24,8 +24,9 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "match the documented shape") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
@@ -66,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "complex JSON/DSL") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "complex JSON/DSL") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "complex JSON/DSL") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
6
shortcuts/base/high_risk.go
Normal file
6
shortcuts/base/high_risk.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
const baseHighRiskYesTip = "This is a high-risk write command. If the user explicitly requested it and the target is unambiguous, pass --yes without asking again."
|
||||
@@ -19,13 +19,14 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch create JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
"Agent hint: use lark-base-cell-value.md as the source of truth for each CellValue.",
|
||||
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch create supports max 200 rows per call.",
|
||||
"Use the record-batch-create guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch update JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
"Agent hint: use lark-base-cell-value.md as the source of truth for each patch CellValue.",
|
||||
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
|
||||
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
`Example: lark-cli base +record-delete --base-token <base_token> --table-id <table_id> --record-id <record_id_1> --record-id <record_id_2> --yes`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
|
||||
@@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "max-version", Type: "int", Desc: "max version for next page"},
|
||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"},
|
||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-history-list --base-token <base_token> --table-id <table_id> --record-id <record_id>`,
|
||||
"This reads one record's history only; it is not a table-wide audit scan.",
|
||||
},
|
||||
DryRun: dryRunRecordHistoryList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -22,6 +22,8 @@ var BaseRecordList = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordListFieldRefFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
@@ -29,10 +31,21 @@ var BaseRecordList = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"Example with projection: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
`Text equality filter: --filter-json '{"logic":"and","conditions":[["Title","==","Launch plan"]]}'`,
|
||||
`Text contains/like filter: --filter-json '{"logic":"and","conditions":[["Title","intersects","urgent"]]}'`,
|
||||
`Number equality filter: --filter-json '{"logic":"and","conditions":[["Score","==",95]]}'`,
|
||||
`Date equality filter: --filter-json '{"logic":"and","conditions":[["Due Date","==","ExactDate(2026-06-02)"]]}'`,
|
||||
`Option intersection filter: --filter-json '{"logic":"and","conditions":[["Tags","intersects",["P0","Blocked"]]]}'`,
|
||||
`Sort priority follows --sort-json array order: --sort-json '[{"field":"Updated","desc":true},{"field":"Title","desc":false}]'`,
|
||||
formatRecordQueryPriorityTip(),
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view.",
|
||||
"For structured filters, sorting, Top/Bottom N, and link fields, follow the lark-base record read SOP.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
|
||||
@@ -15,6 +15,13 @@ import (
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
|
||||
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
|
||||
}
|
||||
|
||||
type recordSelection struct {
|
||||
recordIDs []string
|
||||
selectFields []string
|
||||
@@ -210,6 +217,9 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params.Set("view_id", viewID)
|
||||
}
|
||||
if err := applyRecordQueryToURLValues(runtime, params); err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode()
|
||||
return common.NewDryRunAPI().
|
||||
GET(path).
|
||||
@@ -230,8 +240,12 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
var body map[string]interface{}
|
||||
if strings.TrimSpace(runtime.Str("json")) != "" {
|
||||
body, _ = recordSearchJSONBody(runtime)
|
||||
} else {
|
||||
body, _ = recordSearchFlagBody(runtime)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/search").
|
||||
Body(body).
|
||||
@@ -381,6 +395,9 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
params["view_id"] = viewID
|
||||
}
|
||||
if err := applyRecordQueryToParams(runtime, params); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -413,8 +430,13 @@ func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordSearch(runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
var body map[string]interface{}
|
||||
var err error
|
||||
if strings.TrimSpace(runtime.Str("json")) != "" {
|
||||
body, err = recordSearchJSONBody(runtime)
|
||||
} else {
|
||||
body, err = recordSearchFlagBody(runtime)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
248
shortcuts/base/record_query.go
Normal file
248
shortcuts/base/record_query.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
recordFilterJSONFlag = "filter-json"
|
||||
recordSortJSONFlag = "sort-json"
|
||||
recordSortMaxCount = 10
|
||||
)
|
||||
|
||||
func recordFilterFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: recordFilterJSONFlag,
|
||||
Desc: `filter JSON object or @file, same shape as view filter JSON; overrides --view-id view filters`,
|
||||
Input: []string{common.File},
|
||||
}
|
||||
}
|
||||
|
||||
func recordSortFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: recordSortJSONFlag,
|
||||
Desc: `sort JSON array or @file, e.g. [{"field":"Updated","desc":true}]; also accepts {"sort_config":[...]}; order is priority; max 10`,
|
||||
Input: []string{common.File},
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
|
||||
if _, err := parseRecordFilterFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := parseRecordSortFlag(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
|
||||
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
|
||||
if filterRaw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
return parseJSONObject(pc, filterRaw, recordFilterJSONFlag)
|
||||
}
|
||||
|
||||
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
|
||||
if sortRaw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
value, err := parseJSONValue(pc, sortRaw, recordSortJSONFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeRecordSortValue(value, "--"+recordSortJSONFlag)
|
||||
}
|
||||
|
||||
func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) {
|
||||
var sortConfig []interface{}
|
||||
if parsed, ok := value.([]interface{}); ok {
|
||||
sortConfig = parsed
|
||||
} else if obj, ok := value.(map[string]interface{}); ok {
|
||||
rawSortConfig, ok := obj["sort_config"]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
parsed, ok := rawSortConfig.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
|
||||
}
|
||||
sortConfig = parsed
|
||||
} else {
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
if len(sortConfig) > recordSortMaxCount {
|
||||
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||
}
|
||||
return sortConfig, nil
|
||||
}
|
||||
|
||||
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func applyRecordQueryToParams(runtime *common.RuntimeContext, params map[string]interface{}) error {
|
||||
filter, err := parseRecordFilterFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filter"] = filterJSON
|
||||
}
|
||||
sortConfig, err := parseRecordSortFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sortConfig) > 0 {
|
||||
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["sort"] = sortJSON
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyRecordQueryToURLValues(runtime *common.RuntimeContext, params url.Values) error {
|
||||
filter, err := parseRecordFilterFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filter"] = []string{filterJSON}
|
||||
}
|
||||
sortConfig, err := parseRecordSortFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sortConfig) > 0 {
|
||||
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["sort"] = []string{sortJSON}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]interface{}) error {
|
||||
filter, err := parseRecordFilterFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if filter != nil {
|
||||
body["filter"] = filter
|
||||
}
|
||||
sortConfig, err := parseRecordSortFlag(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sortConfig) > 0 {
|
||||
body["sort"] = sortConfig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{}
|
||||
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
||||
body["keyword"] = keyword
|
||||
}
|
||||
searchFields := runtime.StrArray("search-field")
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
body["view_id"] = viewID
|
||||
}
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
body["offset"] = offset
|
||||
body["limit"] = common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
return body, applyRecordQueryToBody(runtime, body)
|
||||
}
|
||||
|
||||
func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeRecordSearchJSONBody(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, applyRecordQueryToBody(runtime, body)
|
||||
}
|
||||
|
||||
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
|
||||
if rawSort, ok := body["sort"]; ok {
|
||||
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
|
||||
body["sort"] = sortConfig
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return common.FlagErrorf("--keyword is required unless --json is used")
|
||||
}
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
return common.FlagErrorf("--search-field is required unless --json is used")
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
runtime.Changed("offset") ||
|
||||
runtime.Changed("limit")
|
||||
}
|
||||
|
||||
func formatRecordQueryPriorityTip() string {
|
||||
return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user