mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
1 Commits
v1.0.48
...
feat/7564d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a51a2427e |
@@ -57,14 +57,6 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
@@ -73,23 +65,10 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -115,23 +94,6 @@ linters:
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -2,84 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
|
||||
- **contact**: Add contact skill domain guidance (#1144)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Use JSON skills list during update (#1251)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
|
||||
- **vc-agent**: Require explicit leave request (#1260)
|
||||
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
|
||||
- **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
|
||||
@@ -1026,10 +948,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||
|
||||
@@ -90,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
@@ -718,23 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if gotOpts.Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,13 +279,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"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.",
|
||||
"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),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
|
||||
@@ -1042,11 +1042,8 @@ 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",
|
||||
"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",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
|
||||
@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||
// inherited by every subcommand, turning unknown-flag errors into a
|
||||
// structured "did you mean" envelope.
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
|
||||
@@ -341,9 +341,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -383,9 +380,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -425,11 +419,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if result.AppSecret != "" {
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -518,10 +507,5 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -16,7 +17,6 @@ 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,9 +177,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
httpClient := &http.Client{}
|
||||
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)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
// (covering both TAT acquisition and the subsequent probe request).
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// runProbe runs a best-effort credential validation after config init has
|
||||
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||
// deterministic credential-rejection signal; every other outcome returns nil
|
||||
// so that valid configurations and transient/upstream noise never block the
|
||||
// command.
|
||||
//
|
||||
// The function performs up to two HTTP calls in series, bounded by
|
||||
// probeTimeout:
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||
if err != nil {
|
||||
// A typed error from FetchTAT is a deterministic credential rejection
|
||||
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||
// silent and let the command succeed.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
f.probeCalls++
|
||||
f.probeReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
f.probeBody = string(b)
|
||||
}
|
||||
if f.probeHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||
// the caller-supplied RoundTripper.
|
||||
//
|
||||
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||
// touch lands in a temp dir rather than the developer's real config.
|
||||
//
|
||||
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||
// this buffer stays empty as an invariant.
|
||||
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
}
|
||||
return f, errBuf
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("expected nil (silent), got error: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||
// rejection) → silent, exit 0.
|
||||
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500} {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(code, `nope`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(500, `server error`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if rt.probeReq.Method != http.MethodPost {
|
||||
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||
}
|
||||
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||
t.Errorf("probe URL = %s", got)
|
||||
}
|
||||
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||
}
|
||||
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||
}
|
||||
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -153,9 +152,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
httpClient := &http.Client{}
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
type probeResult struct {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,27 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
valid, _ := detail["valid_flags"].([]string)
|
||||
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
398
cmd/root.go
398
cmd/root.go
@@ -18,17 +18,14 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
@@ -51,6 +48,20 @@ EXAMPLES:
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
@@ -72,15 +83,7 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -144,49 +147,29 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -272,13 +255,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command via output.MarkRaw)
|
||||
@@ -291,19 +267,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -345,12 +308,6 @@ func asExitError(err error) *output.ExitError {
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
// consumes no flags itself, so unknown flags belong to the (missing)
|
||||
// subcommand; whitelisting them here prevents cobra from erroring on the
|
||||
// flag first and printing usage instead of our structured suggestion.
|
||||
cmd.FParseErrWhitelist.UnknownFlags = true
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
@@ -370,89 +327,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
available := availableSubcommandNames(cmd)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
@@ -460,114 +342,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -576,95 +361,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
subs = append(subs, name)
|
||||
}
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
|
||||
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
|
||||
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
|
||||
// --find, where edit distance alone finds nothing). Other flag errors stay
|
||||
// structured but generic.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "flag_error",
|
||||
Message: ferr.Error(),
|
||||
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
|
||||
},
|
||||
}
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": "--" + name,
|
||||
"command_path": c.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"valid_flags": valid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
// the valid_flags detail).
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -269,54 +268,6 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -180,7 +180,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
|
||||
@@ -765,22 +765,3 @@ func TestDetectFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("expected runF to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -73,149 +72,6 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "--badflag" {
|
||||
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||
}
|
||||
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||
}
|
||||
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||
if _, present := detail[key]; !present {
|
||||
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||
// must still fail structured (missing_subcommand) rather than fall through to
|
||||
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -257,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||
// back to pointing at --help; the full machine-readable list lives in
|
||||
// detail.available below (which also excludes hidden commands).
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
@@ -308,7 +164,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got, _ := availableSubcommandNames(root)
|
||||
got := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -319,61 +175,3 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g --json":
|
||||
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
|
||||
@@ -155,30 +155,7 @@ caller scripts.
|
||||
|
||||
New code should not reach for `ErrBare` unless the command is
|
||||
genuinely a predicate. Anything carrying recoverable error content
|
||||
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
|
||||
partial-failure outcome below.
|
||||
|
||||
### Partial failure (batch / multi-status)
|
||||
|
||||
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
|
||||
many items can finish in a third state, neither full success nor a single
|
||||
error: some items succeeded and some failed. Its primary output is the
|
||||
per-item result, so it does **not** belong in a `stderr` error envelope.
|
||||
|
||||
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
|
||||
|
||||
1. writes the full result to **stdout** as an `ok:false` envelope — the
|
||||
summary and every per-item outcome (succeeded *and* failed) stay
|
||||
machine-readable, exactly as a successful `Out(...)` would carry them,
|
||||
but with `ok` honestly reporting failure; and
|
||||
2. returns `*output.PartialFailureError`, a typed exit signal the
|
||||
dispatcher maps to a non-zero exit code while writing nothing further
|
||||
to `stderr`.
|
||||
|
||||
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
|
||||
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
|
||||
*result*, reported on stdout, that also failed. Consumers branch on
|
||||
`ok == false` and then read `data.summary` / `data.items[]`.
|
||||
belongs in a typed `*errs.XxxError`.
|
||||
|
||||
## Consumers
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
@@ -61,22 +61,8 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
|
||||
// item (RFC 7807 §3.1 extension members).
|
||||
//
|
||||
// The wire key on ValidationError is "params" rather than "invalid_params"
|
||||
// because the enclosing envelope already carries type:"validation", so the
|
||||
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
|
||||
// InvalidParam prefix because, at package level, the name must self-describe.
|
||||
type InvalidParam struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
@@ -136,11 +122,6 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
|
||||
e.Params = append(e.Params, params...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -558,71 +558,6 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidationError_WithParams covers the structured-validation extension:
|
||||
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
|
||||
// and the wire shape nests {name, reason} under "params" (omitted when empty).
|
||||
func TestValidationError_WithParams(t *testing.T) {
|
||||
t.Run("appends and exposes fields", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
if len(e.Params) != 1 {
|
||||
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
|
||||
}
|
||||
if e.Params[0].Name != "a.md" {
|
||||
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
|
||||
}
|
||||
if e.Params[0].Reason != "duplicate" {
|
||||
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
|
||||
if returned != e {
|
||||
t.Errorf("WithParams returned different pointer; want same as receiver")
|
||||
}
|
||||
e.WithParams(
|
||||
errs.InvalidParam{Name: "b.md", Reason: "dup"},
|
||||
errs.InvalidParam{Name: "c.md", Reason: "dup"},
|
||||
)
|
||||
if len(e.Params) != 3 {
|
||||
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParam("--rel-path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"param":"--rel-path"`,
|
||||
`"params":[{"name":"a.md","reason":"duplicate"}]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Params omitted from wire", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), `"params"`) {
|
||||
t.Errorf("empty Params should be omitted from wire; got %s", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
||||
scopes := []string{"docx:document", "im:message:send"}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// 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 transport.Fallback()
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmdpolicy
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
|
||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||
}
|
||||
best := string(candidates[0])
|
||||
bestDist := suggest.Levenshtein(lowered, best)
|
||||
bestDist := levenshtein(lowered, best)
|
||||
for _, c := range candidates[1:] {
|
||||
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
|
||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
||||
bestDist, best = d, string(c)
|
||||
}
|
||||
}
|
||||
@@ -41,3 +40,47 @@ func toLower(s string) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// levenshtein computes the classic edit distance between two strings.
|
||||
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
|
||||
// makes raw performance irrelevant — clarity beats trickiness here.
|
||||
func levenshtein(a, b string) int {
|
||||
if len(a) == 0 {
|
||||
return len(b)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return len(a)
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
curr := make([]int, len(b)+1)
|
||||
for j := 0; j <= len(b); j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min3(
|
||||
prev[j]+1, // deletion
|
||||
curr[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"wrtie", "write", 2},
|
||||
{"kitten", "sitting", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := levenshtein(c.a, c.b)
|
||||
if got != c.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
_ "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) {
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
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)
|
||||
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)
|
||||
client := &http.Client{
|
||||
Transport: rt,
|
||||
Transport: transport,
|
||||
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()),
|
||||
}
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
util.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 = transport.Shared()
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 1024
|
||||
agentTraceMaxLen = 256
|
||||
)
|
||||
|
||||
// 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/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// 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 transport.Fallback()
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
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 transport.Fallback().RoundTrip(req)
|
||||
return util.FallbackTransport().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 transport.Fallback().RoundTrip(req)
|
||||
return util.FallbackTransport().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 transport.Fallback()
|
||||
return util.FallbackTransport()
|
||||
}
|
||||
|
||||
// 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
|
||||
// transport.Fallback rather than panicking. This covers the fallback
|
||||
// util.FallbackTransport 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,7 +4,9 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -164,9 +166,42 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": acct.AppID,
|
||||
"app_secret": acct.AppSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenResult{Token: token}, nil
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
|
||||
}
|
||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||
type stubRoundTripper struct {
|
||||
gotReq *http.Request
|
||||
gotBody string
|
||||
respCode int
|
||||
respBody string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
s.gotReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
s.gotBody = string(b)
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: s.respCode,
|
||||
Body: io.NopCloser(strings.NewReader(s.respBody)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d: expected error", code)
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
|
||||
sentinel := errors.New("network down")
|
||||
rt := &stubRoundTripper{err: sentinel}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("transport error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("error chain missing sentinel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("parse error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rt.gotReq.URL.String(); got != tc.wantURL {
|
||||
t.Errorf("url = %s, want %s", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ContextCanceled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rt := &urlRewriteRT{base: srv.URL}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-canceled
|
||||
|
||||
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for canceled context")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("error chain missing context.Canceled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// urlRewriteRT forwards requests to a fixed base URL (test server).
|
||||
type urlRewriteRT struct{ base string }
|
||||
|
||||
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
newURL := r.base + req.URL.Path
|
||||
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req2.Header = req.Header
|
||||
return http.DefaultTransport.RoundTrip(req2)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,4 @@ 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,7 +129,6 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
@@ -232,24 +231,6 @@ func ConfigHint(subtype errs.Subtype) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||
func APIHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeConflict:
|
||||
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "operate on source and target within the same tenant and region/unit"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "operate on source and target within the same brand environment"
|
||||
case errs.SubtypeQuotaExceeded:
|
||||
return "reduce the request volume or free quota, then retry after the relevant quota resets"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
|
||||
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
|
||||
// Each case traces to repo evidence (see codemeta_drive.go comments).
|
||||
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 1061044: upload with a nonexistent parent folder token. The drive E2E
|
||||
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
|
||||
// producer via a nonexistent parent folder → referenced resource missing.
|
||||
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
var mailCodeMeta = map[int]CodeMeta{
|
||||
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
|
||||
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
|
||||
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
|
||||
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
|
||||
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
|
||||
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(mailCodeMeta, "mail") }
|
||||
@@ -170,28 +170,6 @@ func ErrBare(code int) *ExitError {
|
||||
return &ExitError{Code: code}
|
||||
}
|
||||
|
||||
// PartialFailureError is the exit signal for a batch / multi-status command that
|
||||
// has already written an ok:false result envelope to stdout. The per-item
|
||||
// outcomes are the primary, machine-readable output and live on stdout, so the
|
||||
// dispatcher sets only the exit code and writes nothing to stderr.
|
||||
//
|
||||
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
|
||||
// so the predicate contract stays narrow, and from a typed *errs.XxxError
|
||||
// (which owns the stderr error envelope): a partial failure is a result, not an
|
||||
// error envelope.
|
||||
type PartialFailureError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func (e *PartialFailureError) Error() string {
|
||||
return fmt.Sprintf("partial failure (exit %d)", e.Code)
|
||||
}
|
||||
|
||||
// PartialFailure builds the partial-failure exit signal with the given code.
|
||||
func PartialFailure(code int) *PartialFailureError {
|
||||
return &PartialFailureError{Code: code}
|
||||
}
|
||||
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
|
||||
@@ -61,10 +61,6 @@ func ExitCodeOf(err error) int {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var pfErr *PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.Code
|
||||
|
||||
@@ -17,7 +17,6 @@ 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"
|
||||
)
|
||||
@@ -179,9 +178,7 @@ 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) {
|
||||
// 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)
|
||||
client := &http.Client{Timeout: fetchTimeout}
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -165,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
}
|
||||
|
||||
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
if r.Err != nil {
|
||||
|
||||
@@ -188,13 +188,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills ls -g",
|
||||
},
|
||||
{
|
||||
name: "list global json",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.ListGlobalSkillsJSON()
|
||||
},
|
||||
want: "-y skills ls -g --json",
|
||||
},
|
||||
{
|
||||
name: "install skill primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -58,28 +57,6 @@ func ParseSkillsList(text string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseGlobalSkillsJSON(text string) []string {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -100,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "Agents:") {
|
||||
continue
|
||||
}
|
||||
|
||||
if isGlobalSkillsSectionHeader(trimmed) {
|
||||
// Skip indented lines (Agents: ...)
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -117,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
|
||||
candidate := parts[0]
|
||||
|
||||
// Validate and add
|
||||
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
||||
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(candidate, "@"); at > 0 {
|
||||
candidate = candidate[:at]
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func isGlobalSkillsSectionHeader(line string) bool {
|
||||
switch line {
|
||||
case "General", "Project", "Local":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
|
||||
func parseOfficialSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -224,7 +195,6 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
@@ -269,9 +239,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
local := []string{}
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult != nil && localResult.Err == nil {
|
||||
local = ParseSkillsList(localResult.Stdout.String())
|
||||
}
|
||||
|
||||
// --- Step 3: Read previous state ---
|
||||
@@ -299,10 +270,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
@@ -327,24 +294,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
textResult := runner.ListGlobalSkills()
|
||||
if textResult != nil && textResult.Err == nil {
|
||||
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
|
||||
// when incremental sync is not possible. On success it writes a state file so that
|
||||
// subsequent syncs can use incremental mode. When official is non-nil the state
|
||||
|
||||
@@ -67,49 +67,6 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
General
|
||||
lark-apps ~/.agents/skills/lark-apps
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-apps", "lark-base"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSON(t *testing.T) {
|
||||
input := `[
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
|
||||
{"name":""},
|
||||
{"name":" "},
|
||||
{"name":"bad skill"}
|
||||
]`
|
||||
got := ParseGlobalSkillsJSON(input)
|
||||
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`[]`,
|
||||
} {
|
||||
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
|
||||
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -156,18 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
@@ -193,19 +146,6 @@ func globalSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsJSONOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
@@ -213,16 +153,7 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
|
||||
f.listedGlobalJSON++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalJSONOut)
|
||||
r.Err = f.globalJSONErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
f.listedGlobalText++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
@@ -255,9 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
@@ -269,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
|
||||
if runner.listedGlobalJSON != 1 {
|
||||
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
|
||||
}
|
||||
if runner.listedGlobalText != 0 {
|
||||
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
@@ -338,115 +262,55 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
|
||||
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
|
||||
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -478,7 +342,6 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
@@ -577,7 +440,6 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -602,7 +464,6 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
@@ -643,9 +504,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package suggest provides the shared "did you mean" primitives: a rune-aware
|
||||
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
|
||||
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
|
||||
// carrying their own copy.
|
||||
package suggest
|
||||
|
||||
import "sort"
|
||||
|
||||
// Levenshtein computes the classic edit distance between two strings. It is
|
||||
// rune-aware, so it is correct for multi-byte input.
|
||||
func Levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
// Closest returns up to maxN of candidates that plausibly match typed, ranked
|
||||
// by shared-prefix length (desc) then edit distance (asc), keeping only
|
||||
// reasonably-close ones.
|
||||
//
|
||||
// Shared prefix is weighted first on purpose: hallucinated names are often
|
||||
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
|
||||
// "--with-styles" vs nothing close), where the common prefix is the strongest
|
||||
// signal of intent that raw edit distance misses.
|
||||
func Closest(typed string, candidates []string, maxN int) []string {
|
||||
type scored struct {
|
||||
name string
|
||||
prefix int
|
||||
dist int
|
||||
}
|
||||
limit := editLimit(typed)
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
p := sharedPrefixLen(typed, c)
|
||||
d := Levenshtein(typed, c)
|
||||
// Keep only plausible matches: a meaningful shared prefix, or an edit
|
||||
// distance within budget. Drop everything else so the hint stays short.
|
||||
if p >= 3 || d <= limit {
|
||||
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
|
||||
}
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].prefix != ranked[j].prefix {
|
||||
return ranked[i].prefix > ranked[j].prefix
|
||||
}
|
||||
if ranked[i].dist != ranked[j].dist {
|
||||
return ranked[i].dist < ranked[j].dist
|
||||
}
|
||||
return ranked[i].name < ranked[j].name
|
||||
})
|
||||
if maxN <= 0 || maxN > len(ranked) {
|
||||
maxN = len(ranked)
|
||||
}
|
||||
out := make([]string, 0, maxN)
|
||||
for _, s := range ranked[:maxN] {
|
||||
out = append(out, s.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// editLimit allows roughly one third of the typed length in edits (min 2), so
|
||||
// short names tolerate a couple of typos and longer ones proportionally more.
|
||||
func editLimit(s string) int {
|
||||
if l := len([]rune(s)) / 3; l > 2 {
|
||||
return l
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func sharedPrefixLen(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
n := 0
|
||||
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
|
||||
cmds := []string{
|
||||
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
|
||||
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
|
||||
"+pivot-create", "+sheet-info",
|
||||
}
|
||||
// "+cells-find" is semantically +cells-search but lexically far; the shared
|
||||
// "+cells-" prefix should still surface the right family (incl. +cells-search).
|
||||
got := Closest("+cells-find", cmds, 6)
|
||||
if len(got) == 0 || len(got) > 6 {
|
||||
t.Fatalf("expected 1..6 suggestions, got %v", got)
|
||||
}
|
||||
if !slices.Contains(got, "+cells-search") {
|
||||
t.Errorf("expected +cells-search among suggestions, got %v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if len(s) < 7 || s[:7] != "+cells-" {
|
||||
t.Errorf("suggestion %q does not share the +cells- prefix", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
|
||||
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
|
||||
if len(got) == 0 || got[0] != "+cells-get" {
|
||||
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_NoPlausibleMatch(t *testing.T) {
|
||||
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
|
||||
t.Errorf("expected no suggestions for unrelated input, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"kitten", "sitting", 3},
|
||||
{"cell-get", "cells-get", 1},
|
||||
{"--query", "--find", 5},
|
||||
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
|
||||
{"飞书", "飞s", 1}, // one rune substitution, not byte count
|
||||
}
|
||||
for _, c := range cases {
|
||||
if d := Levenshtein(c.a, c.b); d != c.want {
|
||||
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPrefixLen(t *testing.T) {
|
||||
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 7", got)
|
||||
}
|
||||
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// 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,258 +0,0 @@
|
||||
// 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/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"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: transport.Shared(),
|
||||
Transport: util.SharedTransport(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
package util
|
||||
|
||||
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"
|
||||
@@ -38,21 +36,8 @@ 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 {
|
||||
@@ -75,22 +60,6 @@ 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
|
||||
}
|
||||
@@ -102,3 +71,48 @@ 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()
|
||||
}
|
||||
204
internal/util/proxy_test.go
Normal file
204
internal/util/proxy_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
|
||||
// has migrated to typed errs.* envelopes. On these paths, calls to common's
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
var legacyCommonHelperReplacements = map[string]string{
|
||||
"FlagErrorf": "common.ValidationErrorf",
|
||||
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
|
||||
"AtLeastOne": "common.AtLeastOneTyped",
|
||||
"ExactlyOne": "common.ExactlyOneTyped",
|
||||
"ValidatePageSize": "common.ValidatePageSizeTyped",
|
||||
"ValidateChatID": "common.ValidateChatIDTyped",
|
||||
"ValidateUserID": "common.ValidateUserIDTyped",
|
||||
"ValidateSafePath": "common.ValidateSafePathTyped",
|
||||
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
|
||||
"WrapInputStatError": "common.WrapInputStatErrorTyped",
|
||||
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
|
||||
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
|
||||
"HandleApiResult": "runtime.CallAPITyped",
|
||||
}
|
||||
|
||||
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
|
||||
// APIs on migrated paths — direct calls and function-value references alike,
|
||||
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
|
||||
// helpers return legacy output envelopes or bare errors, so migrated domains
|
||||
// should use their typed-aware replacements.
|
||||
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
|
||||
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
localNames, dotImported := resolveCommonNames(file)
|
||||
var out []Violation
|
||||
report := func(pos token.Pos, name, replacement string) {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_common_helper_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
|
||||
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
|
||||
})
|
||||
}
|
||||
// Pass 1: qualified references (common.X / alias.X). Record every
|
||||
// selector field so the dot-import pass below never mistakes another
|
||||
// package's same-named field for a common helper.
|
||||
selFields := make(map[*ast.Ident]struct{})
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selFields[sel.Sel] = struct{}{}
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
|
||||
report(sel.Pos(), sel.Sel.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Pass 2: unqualified references under a dot import.
|
||||
if dotImported {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ident, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, isField := selFields[ident]; isField {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
|
||||
report(ident.Pos(), ident.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isMigratedCommonHelperPath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedCommonHelperPaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != commonImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
names["common"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
|
||||
// to the typed errs.* taxonomy. On these paths, constructing a legacy
|
||||
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
|
||||
// name (default or alias) this path is bound to in each file, so an aliased
|
||||
// import cannot bypass the check.
|
||||
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
|
||||
|
||||
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
|
||||
// output.ExitError / output.ErrDetail composite literals on migrated paths.
|
||||
// forbidigo can ban identifiers but not composite literals, so this AST rule
|
||||
// covers the gap left after a path is migrated to typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
|
||||
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
|
||||
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
|
||||
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Resolve the local name(s) bound to the legacy output import path. A file
|
||||
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
|
||||
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
|
||||
// as bare unqualified idents.
|
||||
localNames, dotImported := resolveLegacyOutputNames(file)
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_envelope_literal",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(lit.Pos()).Line,
|
||||
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
|
||||
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
|
||||
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// isMigratedEnvelopePath reports whether path falls under any migrated path
|
||||
// prefix in migratedEnvelopePaths.
|
||||
func isMigratedEnvelopePath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedEnvelopePaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveLegacyOutputNames walks the file's import declarations and returns the
|
||||
// set of local names bound to legacyOutputImportPath, plus whether the path was
|
||||
// dot-imported. Default imports bind the package's own name ("output"); aliased
|
||||
// imports bind the alias; dot-imports bind names into the file scope.
|
||||
func resolveLegacyOutputNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != legacyOutputImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
// Default import: local name is the package name "output".
|
||||
names["output"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
// Blank import cannot reference the types; ignore.
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
|
||||
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
|
||||
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
|
||||
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
|
||||
// local names for the legacy output import, and — when the package was
|
||||
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
|
||||
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
|
||||
if sel, ok := expr.(*ast.SelectorExpr); ok {
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok || sel.Sel == nil {
|
||||
return "", false
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return "", false
|
||||
}
|
||||
return matchLegacyEnvelopeName(sel.Sel.Name)
|
||||
}
|
||||
if dotImported {
|
||||
if ident, ok := expr.(*ast.Ident); ok {
|
||||
return matchLegacyEnvelopeName(ident.Name)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// matchLegacyEnvelopeName returns the name when it is one of the legacy
|
||||
// envelope type names.
|
||||
func matchLegacyEnvelopeName(name string) (string, bool) {
|
||||
switch name {
|
||||
case "ExitError", "ErrDetail":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy
|
||||
// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on
|
||||
// migrated paths. Those helpers route failures through common.HandleApiResult /
|
||||
// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and
|
||||
// downgrade an already-typed network / auth boundary error into an API error.
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper
|
||||
// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel == nil {
|
||||
return true
|
||||
}
|
||||
if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_runtime_api_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's
|
||||
// legacy auto-classifying API helper methods.
|
||||
func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
switch name {
|
||||
case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID":
|
||||
return name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -593,413 +593,3 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
|
||||
t.Errorf("message must name the offending call: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
|
||||
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() *output.ErrDetail {
|
||||
return &output.ErrDetail{Code: 7}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ErrDetail") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
|
||||
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
|
||||
// Same offending literal, but outside the migrated path set → not flagged.
|
||||
src := `package other
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("_test.go file should be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
|
||||
// import of internal/output cannot bypass the rule: the qualifier is resolved
|
||||
// from the import declaration, not matched against the literal string "output".
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &legacy.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
|
||||
// regression where resolving by import path accidentally drops the default
|
||||
// (non-aliased) `output` case.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
|
||||
// a CallExpr, not a composite literal — even under an alias it must not fire.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import legacy "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return legacy.ErrBare(legacy.ExitAPI)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("ErrBare call should pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
|
||||
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
|
||||
// the composite literal.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
|
||||
// selector on an unrelated package (not the legacy output import path) must not
|
||||
// trigger a false positive.
|
||||
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "example.com/other/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("unrelated package selector must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
|
||||
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed wrapper call must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
|
||||
// RawAPI / DoAPI return the raw response for the caller to classify and do
|
||||
// not emit a legacy envelope — they are not banned.
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
|
||||
_, err := runtime.DoAPI(nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("test files must be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
|
||||
helpers := []string{
|
||||
"FlagErrorf",
|
||||
"MutuallyExclusive",
|
||||
"AtLeastOne",
|
||||
"ExactlyOne",
|
||||
"ValidatePageSize",
|
||||
"ValidateChatID",
|
||||
"ValidateUserID",
|
||||
"ValidateSafePath",
|
||||
"RejectDangerousChars",
|
||||
"WrapInputStatError",
|
||||
"WrapSaveErrorByCategory",
|
||||
"ResolveOpenIDs",
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
t.Run(path+"_"+helper, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.` + helper + `()
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "common."+helper) {
|
||||
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.ValidationErrorf("typed")
|
||||
common.MutuallyExclusiveTyped(nil, "a", "b")
|
||||
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
|
||||
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
|
||||
common.WrapSaveErrorTyped(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed helpers must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
c.FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() error {
|
||||
f := common.FlagErrorf
|
||||
return f("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,6 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckNoRegistrar(rel, string(src))...)
|
||||
all = append(all, CheckAdHocSubtype(rel, string(src))...)
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
|
||||
// Typed-error invariants — self-scope to errs/ + classify.go.
|
||||
all = append(all, CheckNilSafeError(rel, string(src))...)
|
||||
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.44",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -25,10 +25,6 @@ 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,9 +25,6 @@ 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")
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-create",
|
||||
Description: "Create a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
|
||||
"Creates a folder, table, docx, dashboard, or workflow entry.",
|
||||
"Do not pass null for --parent-id. Omit it to create at the root level.",
|
||||
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockCreate(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockCreate(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-delete",
|
||||
Description: "Delete a block",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:block:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Deletes the block identified by --block-id.",
|
||||
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
|
||||
"Different block types may have independent backing resources; deletion follows backend semantics.",
|
||||
"Use +base-block-list first when you need to confirm the target block id.",
|
||||
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockDelete(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockList = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-list",
|
||||
Description: "List blocks in a base",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:block:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token>",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
|
||||
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
|
||||
"This command returns the full backend list. It intentionally does not expose limit or offset.",
|
||||
"Pass --type to list only one resource type.",
|
||||
"Pass --parent-id to list only direct children of a folder.",
|
||||
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockList(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockMove = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-move",
|
||||
Description: "Move a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
|
||||
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
|
||||
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
"Omit --parent-id to move the block to root; do not pass null.",
|
||||
"--before-id and --after-id are mutually exclusive.",
|
||||
"When moving a folder, its children remain under that folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockMove(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockMove,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockMove(runtime)
|
||||
},
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
|
||||
|
||||
func baseBlockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
|
||||
}
|
||||
|
||||
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(buildBaseBlockListBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks").
|
||||
Body(buildBaseBlockCreateBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
|
||||
Body(buildBaseBlockMoveBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
|
||||
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return common.FlagErrorf("--type must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockList(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
|
||||
if blockType == "" {
|
||||
return
|
||||
}
|
||||
blocks, ok := data["blocks"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]interface{})
|
||||
if !ok || blockMap["type"] != blockType {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
data["blocks"] = filtered
|
||||
data["total"] = len(filtered)
|
||||
}
|
||||
|
||||
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"type": strings.TrimSpace(runtime.Str("type")),
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"parent_id": nil}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
|
||||
body["before_id"] = beforeID
|
||||
}
|
||||
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
|
||||
body["after_id"] = afterID
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockRename = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-rename",
|
||||
Description: "Rename a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
|
||||
"Renames the block identified by --block-id.",
|
||||
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
|
||||
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockRename(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockRename,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockRename(runtime)
|
||||
},
|
||||
}
|
||||
@@ -24,11 +24,6 @@ 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,10 +22,6 @@ 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,12 +20,7 @@ var BaseDataQuery = common.Shortcut{
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(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.",
|
||||
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
var dsl map[string]interface{}
|
||||
|
||||
@@ -32,29 +32,6 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
}
|
||||
|
||||
func TestDryRunBaseBlockOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
|
||||
|
||||
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
|
||||
|
||||
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
|
||||
|
||||
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
|
||||
|
||||
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
|
||||
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
|
||||
}
|
||||
|
||||
func TestDryRunFieldOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -94,29 +71,6 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
filteredListRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"sort-json": `[{"field":"Due","desc":true}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordList(ctx, filteredListRT),
|
||||
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records",
|
||||
"limit=20",
|
||||
"filter=%7B",
|
||||
"Status",
|
||||
"Todo",
|
||||
"sort=%5B",
|
||||
"Due",
|
||||
)
|
||||
|
||||
commaFieldRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-id": {"A,B", "C"}},
|
||||
@@ -145,33 +99,6 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
`"limit":500`,
|
||||
)
|
||||
|
||||
searchFlagRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"keyword": "Alice",
|
||||
"view-id": "viw_1",
|
||||
"filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`,
|
||||
"sort-json": `[{"field":"Updated At","desc":true}]`,
|
||||
},
|
||||
map[string][]string{
|
||||
"search-field": {"Name"},
|
||||
"field-id": {"Name", "Status"},
|
||||
},
|
||||
nil,
|
||||
map[string]int{"limit": 20},
|
||||
)
|
||||
assertDryRunContains(
|
||||
t,
|
||||
dryRunRecordSearch(ctx, searchFlagRT),
|
||||
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
|
||||
`"keyword":"Alice"`,
|
||||
`"search_fields":["Name"]`,
|
||||
`"select_fields":["Name","Status"]`,
|
||||
`"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`,
|
||||
`"sort":[{"desc":true,"field":"Updated At"}]`,
|
||||
)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
|
||||
@@ -411,108 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
listStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
|
||||
},
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
},
|
||||
}
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
|
||||
},
|
||||
}
|
||||
renameStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
|
||||
},
|
||||
}
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc"},
|
||||
},
|
||||
}
|
||||
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
|
||||
t.Fatalf("list err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
|
||||
t.Fatalf("list stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
|
||||
t.Fatalf("list body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("create stdout=%s", got)
|
||||
}
|
||||
createBody := decodeCapturedJSONBody(t, createStub)
|
||||
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
|
||||
t.Fatalf("create body=%#v", createBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
|
||||
t.Fatalf("move err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
|
||||
t.Fatalf("move stdout=%s", got)
|
||||
}
|
||||
moveBody := decodeCapturedJSONBody(t, moveStub)
|
||||
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
|
||||
t.Fatalf("move body=%#v", moveBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
|
||||
t.Fatalf("rename stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
|
||||
t.Fatalf("rename body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("delete err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("delete stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -617,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(), "match the documented shape") {
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
@@ -1076,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
|
||||
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
@@ -1092,121 +990,12 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
!strings.Contains(body, `"keyword":"Created"`) ||
|
||||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
||||
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
|
||||
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
|
||||
!strings.Contains(body, `"offset":0`) ||
|
||||
!strings.Contains(body, `"limit":2`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with flag filter sort and projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title", "Status"},
|
||||
"field_id_list": []interface{}{"fld_title", "fld_status"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "Created",
|
||||
"--search-field", "Title",
|
||||
"--field-id", "Title",
|
||||
"--field-id", "Status",
|
||||
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
||||
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
|
||||
"--limit", "20",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
|
||||
}
|
||||
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
|
||||
t.Fatalf("captured body=%#v", body)
|
||||
}
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
if filter["logic"] != "and" {
|
||||
t.Fatalf("filter=%#v", filter)
|
||||
}
|
||||
conditions := filter["conditions"].([]interface{})
|
||||
if len(conditions) != 2 {
|
||||
t.Fatalf("conditions=%#v", conditions)
|
||||
}
|
||||
sortConfig := body["sort"].([]interface{})
|
||||
if len(sortConfig) != 2 {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
firstSort := sortConfig[0].(map[string]interface{})
|
||||
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
|
||||
t.Fatalf("sort=%#v", sortConfig)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search with filter json file", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
tmp := t.TempDir()
|
||||
withBaseWorkingDir(t, tmp)
|
||||
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
|
||||
t.Fatalf("write filter err=%v", err)
|
||||
}
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Title"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A"}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(
|
||||
t,
|
||||
BaseRecordSearch,
|
||||
[]string{
|
||||
"+record-search",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--keyword", "A",
|
||||
"--search-field", "Title",
|
||||
"--filter-json", "@filter.json",
|
||||
"--format", "json",
|
||||
},
|
||||
factory,
|
||||
stdout,
|
||||
); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
body := string(searchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search markdown format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -25,9 +25,6 @@ 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,10 +22,6 @@ 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,9 +25,6 @@ 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,9 +25,6 @@ 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,10 +17,7 @@ var BaseBaseGet = common.Shortcut{
|
||||
Scopes: []string{"base:app:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true)},
|
||||
Tips: []string{
|
||||
"Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.",
|
||||
},
|
||||
DryRun: dryRunBaseGet,
|
||||
DryRun: dryRunBaseGet,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseGet(runtime)
|
||||
},
|
||||
|
||||
@@ -25,12 +25,7 @@ var BaseRoleCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", 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.",
|
||||
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -26,12 +26,6 @@ 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,10 +27,6 @@ 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,10 +26,6 @@ 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,13 +26,7 @@ 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: "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.",
|
||||
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
},
|
||||
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; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName)
|
||||
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)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -133,7 +133,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
@@ -189,7 +188,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
@@ -200,25 +198,6 @@ 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{})
|
||||
@@ -243,30 +222,6 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
err := validateBaseBlockMove(runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
|
||||
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
|
||||
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
|
||||
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -280,39 +235,36 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
wantHelp: []string{
|
||||
"field ID or name to include; repeat to project only needed fields",
|
||||
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"pagination size, range 1-200",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
|
||||
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
|
||||
"Text equality filter",
|
||||
"Option intersection filter",
|
||||
"Query priority",
|
||||
"Default output is markdown",
|
||||
"Use --field-id repeatedly to keep output small",
|
||||
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
|
||||
"keyword for record search",
|
||||
"field ID or name to search",
|
||||
`filter JSON object or @file`,
|
||||
`sort JSON array or @file`,
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"Example: lark-cli base +record-search",
|
||||
"Example with filter/sort JSON",
|
||||
"Text equality filter",
|
||||
"Query priority",
|
||||
"Use --json only when you need to pass the full search body directly",
|
||||
`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",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -359,474 +311,6 @@ 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 TestBaseBlockHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: BaseBaseBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-list --base-token <base_token>",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`--type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"returned id is the table-id, dashboard-id, or workflow-id",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: BaseBaseBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Recursive folder deletion is not supported.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -844,7 +328,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 "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
@@ -988,11 +472,11 @@ func TestBaseTableValidate(t *testing.T) {
|
||||
|
||||
func TestBaseRecordValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if BaseRecordList.Validate == nil {
|
||||
t.Fatalf("record list validate should reject invalid query flags before dry-run")
|
||||
if BaseRecordList.Validate != nil {
|
||||
t.Fatalf("record list validate should be nil for repeatable --field-id")
|
||||
}
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
@@ -1003,58 +487,6 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
|
||||
t.Fatalf("record upsert map validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record list filter-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search flag validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
"sort-json": `[{"field":"Title","desc":false}]`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
|
||||
@@ -22,9 +22,6 @@ 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,19 +25,10 @@ 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; 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: "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: "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") {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user