mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
feat/lark-
...
feat/sidec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52dc09af95 | ||
|
|
07da0c8090 | ||
|
|
0aa9e96d18 | ||
|
|
e57d97f341 | ||
|
|
57ba4fae61 | ||
|
|
925ae5ecd6 | ||
|
|
4710a294f5 |
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
@@ -179,7 +179,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 := util.NewHTTPClient(0)
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
|
||||
@@ -19,8 +19,8 @@ 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"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
// DoctorOptions holds inputs for the doctor command.
|
||||
@@ -155,7 +155,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 := util.NewHTTPClient(0)
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
type probeResult struct {
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
||||
// URL), all outgoing requests are rewritten to the sidecar address. The
|
||||
// interceptor strips placeholder credentials, injects proxy headers, and
|
||||
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
||||
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
||||
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -46,15 +47,17 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
@@ -130,8 +133,13 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
||||
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
||||
scheme := i.sidecarScheme
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
req.URL.Scheme = scheme
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
|
||||
@@ -7,11 +7,13 @@ package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
@@ -97,6 +99,54 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
||||
// rewrites the request to https://<remote-host>, while still preserving the
|
||||
// original target and signing the request.
|
||||
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if req.URL.Scheme != "https" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
||||
}
|
||||
if req.URL.Host != "sidecar.mycorp.com" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
||||
}
|
||||
// Original target still preserved for the sidecar to forward upstream.
|
||||
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
// Request is still signed.
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
||||
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
||||
// https, never silently downgrading a remote sidecar to plaintext http.
|
||||
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
||||
t.Setenv(envvars.CliProxyKey, "key")
|
||||
|
||||
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
||||
si, ok := ic.(*Interceptor)
|
||||
if !ok || si == nil {
|
||||
t.Fatalf("expected *Interceptor, got %T", ic)
|
||||
}
|
||||
if si.sidecarScheme != "https" {
|
||||
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
||||
}
|
||||
if si.sidecarHost != "sidecar.mycorp.com" {
|
||||
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.SharedTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
transport = wrapWithExtension(transport)
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
rt = wrapWithExtension(rt)
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Transport: rt,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
||||
|
||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||
// the transport still sets X-Cli-Build and routes the request through
|
||||
// util.FallbackTransport rather than panicking. This covers the fallback
|
||||
// transport.Fallback rather than panicking. This covers the fallback
|
||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||
var receivedBuild string
|
||||
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||
|
||||
// Sidecar proxy (auth proxy mode)
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||
|
||||
// Content safety scanning mode
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -181,7 +181,7 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
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 := util.NewHTTPClient(fetchTimeout)
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode.
|
||||
// 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.
|
||||
//
|
||||
// It supports:
|
||||
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
|
||||
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
|
||||
//
|
||||
// Environment variables override matching values from proxy_config.json.
|
||||
package proxyplugin
|
||||
// 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"
|
||||
@@ -222,21 +223,6 @@ func (c *Config) proxyURL() (*url.URL, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// redactProxyURL masks userinfo (username:password) in a proxy URL.
|
||||
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
|
||||
func redactProxyURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.User != nil {
|
||||
u.User = url.User("***")
|
||||
return u.String()
|
||||
}
|
||||
// Fallback: handle "user:pass@proxy:8080"
|
||||
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||
return "***@" + raw[at+1:]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
83
internal/transport/shared.go
Normal file
83
internal/transport/shared.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
//
|
||||
// Precedence (highest first):
|
||||
// 1. proxy-plugin mode — force traffic through a fixed loopback proxy;
|
||||
// FAIL-CLOSED when the plugin config exists but is invalid.
|
||||
// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled.
|
||||
// 3. http.DefaultTransport — the stdlib process-wide singleton (honors
|
||||
// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache.
|
||||
//
|
||||
// The returned RoundTripper MUST NOT be mutated. Callers that need a customized
|
||||
// transport should assert to *http.Transport and Clone() it. A shared base is
|
||||
// required so persistConn read/write goroutines are reused; cloning per call
|
||||
// leaks them until IdleConnTimeout (~90s) fires.
|
||||
func Shared() http.RoundTripper {
|
||||
// Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When
|
||||
// the plugin config exists but is invalid, pluginTransport returns a
|
||||
// fail-closed transport with ok=true and we return it here — we MUST NOT
|
||||
// fall through to the NO_PROXY / DefaultTransport direct-egress paths below.
|
||||
if t, ok := pluginTransport(); ok {
|
||||
return t
|
||||
}
|
||||
if os.Getenv(EnvNoProxy) != "" {
|
||||
return noProxyTransport()
|
||||
}
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared
|
||||
// retained so modules already on the leak-free singleton path (internal/auth,
|
||||
// internal/cmdutil transport decorators) do not have to migrate. New code
|
||||
// should prefer Shared and treat the base as an http.RoundTripper.
|
||||
//
|
||||
// Fail-closed invariant: pluginTransport always expresses its blocked transport
|
||||
// as a concrete *http.Transport (see failClosedTransport), so the assertion
|
||||
// below preserves the block. The noProxyTransport() fallback is therefore only
|
||||
// reached when no proxy plugin is configured and some external code replaced
|
||||
// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed
|
||||
// intent, where a proxy-disabled transport is acceptable.
|
||||
func Fallback() *http.Transport {
|
||||
if t, ok := Shared().(*http.Transport); ok {
|
||||
return t
|
||||
}
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
|
||||
// for outbound requests: a bare client falls back to http.DefaultTransport and
|
||||
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
|
||||
// fail-closed), creating an audit blind spot.
|
||||
//
|
||||
// A zero timeout means no client-level timeout (callers relying on context
|
||||
// deadlines pass 0).
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: Shared(),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
|
||||
// built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
def, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
t := def.Clone()
|
||||
t.Proxy = nil
|
||||
return t
|
||||
})
|
||||
156
internal/transport/shared_test.go
Normal file
156
internal/transport/shared_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
||||
func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
if Shared() != http.DefaultTransport {
|
||||
t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
||||
func TestShared_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
tr := Shared()
|
||||
if tr == http.DefaultTransport {
|
||||
t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set")
|
||||
}
|
||||
ht, ok := tr.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", tr)
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("no-proxy transport should have Proxy == nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
||||
func TestShared_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
if Shared() != Shared() {
|
||||
t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib
|
||||
// transport after unsetting LARK_CLI_NO_PROXY.
|
||||
func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
|
||||
// the no-proxy singleton), then unsets it. Subsequent calls must return
|
||||
// http.DefaultTransport, NOT the cached no-proxy clone.
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
if Shared() == http.DefaultTransport {
|
||||
t.Fatal("precondition: first call with env set should not return DefaultTransport")
|
||||
}
|
||||
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
if after := Shared(); after != http.DefaultTransport {
|
||||
t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies.
|
||||
func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
ht, ok := Shared().(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("expected *http.Transport, got %T", Shared())
|
||||
}
|
||||
if ht.Proxy != nil {
|
||||
t.Error("LARK_CLI_NO_PROXY should override system proxy settings")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
||||
// transport (instead of a bare client that bypasses proxy plugin mode).
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
c := NewHTTPClient(7 * time.Second)
|
||||
if c.Transport == nil {
|
||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||
}
|
||||
if c.Transport != Shared() {
|
||||
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
|
||||
}
|
||||
if c.Timeout != 7*time.Second {
|
||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins
|
||||
// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy.
|
||||
func TestShared_PluginOverridesNoProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{
|
||||
"LARKSUITE_CLI_PROXY_ENABLE": true,
|
||||
"LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128"
|
||||
}`), 0600)
|
||||
|
||||
tr, ok := Shared().(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr)
|
||||
}
|
||||
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" {
|
||||
t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous
|
||||
// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never
|
||||
// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone.
|
||||
func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600) // malformed
|
||||
|
||||
rt := Shared()
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatal("malformed config returned http.DefaultTransport — fail OPEN")
|
||||
}
|
||||
if rt == noProxyTransport() {
|
||||
t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN")
|
||||
}
|
||||
resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -16,7 +16,7 @@ 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 SharedTransport call.
|
||||
// http.DefaultTransport on every pluginTransport call.
|
||||
var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||
|
||||
func buildBlockedTransport() http.RoundTripper {
|
||||
@@ -28,7 +28,7 @@ func buildProxyPluginTransport() http.RoundTripper {
|
||||
if !ok {
|
||||
// Cannot clone the stdlib transport. Fail closed with a concrete
|
||||
// *http.Transport (not a bare RoundTripper) so downcasting callers such
|
||||
// as util.FallbackTransport cannot silently degrade this into a
|
||||
// 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))
|
||||
}
|
||||
@@ -51,9 +51,9 @@ func buildProxyPluginTransport() http.RoundTripper {
|
||||
return t
|
||||
}
|
||||
|
||||
// SharedTransport returns the proxy plugin transport when proxy plugin mode is
|
||||
// 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 SharedTransport() (http.RoundTripper, bool) {
|
||||
func pluginTransport() (http.RoundTripper, bool) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
return cachedBlockedTransport(), true
|
||||
@@ -68,7 +68,7 @@ func SharedTransport() (http.RoundTripper, bool) {
|
||||
// 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 util.FallbackTransport cannot silently degrade a fail-closed
|
||||
// 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 {
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package proxyplugin
|
||||
package transport
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -20,21 +20,21 @@ func resetProxyPluginState() {
|
||||
cachedBlockedTransport = sync.OnceValue(buildBlockedTransport)
|
||||
}
|
||||
|
||||
func TestSharedTransport_NotConfigured(t *testing.T) {
|
||||
func TestPluginTransport_NotConfigured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
tr, ok := SharedTransport()
|
||||
tr, ok := pluginTransport()
|
||||
if ok {
|
||||
t.Fatalf("SharedTransport() ok = true, want false")
|
||||
t.Fatalf("pluginTransport() ok = true, want false")
|
||||
}
|
||||
if tr != nil {
|
||||
t.Fatalf("SharedTransport() transport = %T, want nil", tr)
|
||||
t.Fatalf("pluginTransport() transport = %T, want nil", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
@@ -46,13 +46,13 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
"LARKSUITE_CLI_CA_PATH": ""
|
||||
}`), 0600)
|
||||
|
||||
rt, ok := SharedTransport()
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
tr, ok := rt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("SharedTransport() = %T, want *http.Transport", rt)
|
||||
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 {
|
||||
@@ -63,7 +63,7 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
||||
func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
@@ -72,12 +72,12 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt, ok := SharedTransport()
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if rt == http.DefaultTransport {
|
||||
t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport")
|
||||
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 {
|
||||
@@ -88,23 +88,23 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
||||
func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
a, ok := SharedTransport()
|
||||
a, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
b, ok := SharedTransport()
|
||||
b, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached")
|
||||
t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,13 +148,13 @@ func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the
|
||||
// fail-closed invariant that util.FallbackTransport relies on: even when
|
||||
// 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, util.FallbackTransport would downcast-fail and
|
||||
// were a bare RoundTripper, Fallback would downcast-fail and
|
||||
// silently degrade it into a direct-egress transport.
|
||||
func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
@@ -163,12 +163,12 @@ func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) {
|
||||
|
||||
writeFile(t, Path(), []byte(`{`), 0600)
|
||||
|
||||
rt, ok := SharedTransport()
|
||||
rt, ok := pluginTransport()
|
||||
if !ok {
|
||||
t.Fatal("SharedTransport() ok = false, want true")
|
||||
t.Fatal("pluginTransport() ok = false, want true")
|
||||
}
|
||||
if _, isTransport := rt.(*http.Transport); !isTransport {
|
||||
t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt)
|
||||
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"}})
|
||||
104
internal/transport/warn.go
Normal file
104
internal/transport/warn.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
)
|
||||
|
||||
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
||||
var proxyEnvKeys = []string{
|
||||
"HTTPS_PROXY", "https_proxy",
|
||||
"HTTP_PROXY", "http_proxy",
|
||||
"ALL_PROXY", "all_proxy",
|
||||
}
|
||||
|
||||
// DetectProxyEnv returns the first proxy-related environment variable that is set,
|
||||
// or empty strings if none are configured.
|
||||
func DetectProxyEnv() (key, value string) {
|
||||
for _, k := range proxyEnvKeys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return k, v
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// Try standard url.Parse first (works when scheme is present)
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.User != nil {
|
||||
return u.Scheme + "://***@" + u.Host + u.RequestURI()
|
||||
}
|
||||
|
||||
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
|
||||
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||
return "***@" + raw[at+1:]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
|
||||
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
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
|
||||
}
|
||||
key, val := DetectProxyEnv()
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
||||
key, redactProxyURL(val), EnvNoProxy)
|
||||
})
|
||||
}
|
||||
@@ -1,44 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
// Ensure developer machine env doesn't accidentally enable proxy plugin mode
|
||||
// and change expectations for SharedTransport().
|
||||
unsetEnv(t, envvars.CliProxyEnable)
|
||||
unsetEnv(t, envvars.CliProxyAddress)
|
||||
unsetEnv(t, envvars.CliCAPath)
|
||||
}
|
||||
|
||||
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
|
||||
func TestDetectProxyEnv(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -61,88 +34,11 @@ func TestDetectProxyEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
|
||||
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
tr := SharedTransport()
|
||||
if tr != http.DefaultTransport {
|
||||
t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
|
||||
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
|
||||
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
|
||||
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
|
||||
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Reset the once guard for this test
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128")
|
||||
@@ -166,6 +62,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
|
||||
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
for _, k := range proxyEnvKeys {
|
||||
@@ -180,10 +77,11 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
|
||||
// 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")
|
||||
@@ -201,6 +99,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
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")
|
||||
@@ -257,7 +156,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is
|
||||
// trusted, the warning surfaces the TLS-interception capability (V3).
|
||||
// trusted, the warning surfaces the TLS-interception capability.
|
||||
func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
@@ -284,25 +183,6 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
c := NewHTTPClient(7 * time.Second)
|
||||
if c.Transport == nil {
|
||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||
}
|
||||
if c.Transport != SharedTransport() {
|
||||
t.Errorf("NewHTTPClient transport = %v, want SharedTransport()", c.Transport)
|
||||
}
|
||||
if c.Timeout != 7*time.Second {
|
||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin
|
||||
// warning never leaks credentials embedded in the configured proxy address.
|
||||
func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
@@ -331,8 +211,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) {
|
||||
|
||||
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
|
||||
func TestRedactProxyURL(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
@@ -359,6 +237,7 @@ func TestRedactProxyURL(t *testing.T) {
|
||||
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")
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -64,7 +64,7 @@ func httpClient() *http.Client {
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: util.SharedTransport(),
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/proxyplugin"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
||||
var proxyEnvKeys = []string{
|
||||
"HTTPS_PROXY", "https_proxy",
|
||||
"HTTP_PROXY", "http_proxy",
|
||||
"ALL_PROXY", "all_proxy",
|
||||
}
|
||||
|
||||
// DetectProxyEnv returns the first proxy-related environment variable that is set,
|
||||
// or empty strings if none are configured.
|
||||
func DetectProxyEnv() (key, value string) {
|
||||
for _, k := range proxyEnvKeys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return k, v
|
||||
}
|
||||
}
|
||||
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 proxyplugin.Load() sync.Once cache.
|
||||
var proxyPluginStatus = func() (addr, caPath string, enabled bool) {
|
||||
cfg, err := proxyplugin.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 {
|
||||
// Try standard url.Parse first (works when scheme is present)
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.User != nil {
|
||||
return u.Scheme + "://***@" + u.Host + u.RequestURI()
|
||||
}
|
||||
|
||||
// Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080")
|
||||
if at := strings.LastIndex(raw, "@"); at > 0 {
|
||||
return "***@" + raw[at+1:]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
// WarnIfProxied prints a one-time warning to w when a proxy environment variable
|
||||
// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
func WarnIfProxied(w io.Writer) {
|
||||
proxyWarningOnce.Do(func() {
|
||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||
// SharedTransport), 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, proxyplugin.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
|
||||
}
|
||||
key, val := DetectProxyEnv()
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
||||
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 {
|
||||
// proxy plugin mode overrides all other proxy behavior (env proxies and
|
||||
// LARK_CLI_NO_PROXY), per operator intent.
|
||||
if t, ok := proxyplugin.SharedTransport(); ok {
|
||||
return t
|
||||
}
|
||||
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.
|
||||
//
|
||||
// Fail-closed invariant: proxyplugin always expresses its blocked/fail-closed
|
||||
// transport as a concrete *http.Transport (see proxyplugin.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 FallbackTransport() *http.Transport {
|
||||
if t, ok := SharedTransport().(*http.Transport); ok {
|
||||
return t
|
||||
}
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||
// proxy-plugin-aware base (see SharedTransport). 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: SharedTransport(),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
"Disabling advanced permissions invalidates existing custom roles; confirm the target Base before passing --yes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Caller must be a Base admin; enable advanced permissions before creating or updating roles.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -24,6 +24,11 @@ var BaseBaseCopy = common.Shortcut{
|
||||
{Name: "without-content", Type: "bool", Desc: "copy structure only"},
|
||||
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +base-copy --base-token <base_token> --name "Copy of Project Tracker"`,
|
||||
"Use --without-content when the user wants only structure.",
|
||||
"If copied as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
|
||||
},
|
||||
DryRun: dryRunBaseCopy,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseCopy(runtime)
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseBaseCreate = common.Shortcut{
|
||||
{Name: "folder-token", Desc: "folder token for destination"},
|
||||
{Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`,
|
||||
"If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.",
|
||||
},
|
||||
DryRun: dryRunBaseCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseCreate(runtime)
|
||||
|
||||
@@ -20,7 +20,12 @@ var BaseDataQuery = common.Shortcut{
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true},
|
||||
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use +data-query for server-side aggregation, grouping, filtering, sorting, and Top N queries.",
|
||||
"Read lark-base-data-query-guide.md for common fewshots; use lark-base-data-query.md only when the full DSL reference is needed.",
|
||||
"`dimensions` and `measures` cannot both be empty.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
var dsl map[string]interface{}
|
||||
|
||||
@@ -515,7 +515,7 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lark-base skill") {
|
||||
if !strings.Contains(err.Error(), "match the documented shape") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "array") {
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormCreate = common.Shortcut{
|
||||
{Name: "name", Desc: "form name", Required: true},
|
||||
{Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`},
|
||||
},
|
||||
Tips: []string{
|
||||
"Record the returned form_id; form question create/list/update/delete commands need it.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms").
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseFormDelete = common.Shortcut{
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use +form-list or +form-get first when the form target is ambiguous.",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id").
|
||||
|
||||
@@ -23,7 +23,7 @@ var BaseFormsList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size per request, max 100"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
|
||||
@@ -25,6 +25,9 @@ var BaseFormQuestionsList = common.Shortcut{
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use returned question id values for +form-questions-update and +form-questions-delete.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
|
||||
@@ -17,7 +17,10 @@ var BaseBaseGet = common.Shortcut{
|
||||
Scopes: []string{"base:app:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true)},
|
||||
DryRun: dryRunBaseGet,
|
||||
Tips: []string{
|
||||
"Use a real Base token; workspace tokens and wiki tokens are not accepted by this command.",
|
||||
},
|
||||
DryRun: dryRunBaseGet,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseGet(runtime)
|
||||
},
|
||||
|
||||
@@ -25,7 +25,12 @@ var BaseRoleCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
{Name: "json", Desc: "role config JSON; read lark-base-role-guide.md and role-config.md before constructing permissions", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
|
||||
"Create supports custom_role only.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -26,6 +26,12 @@ var BaseRoleDelete = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Only custom roles can be deleted; system roles cannot be deleted.",
|
||||
"Use +role-get first if the role target is ambiguous, then pass --yes to confirm deletion.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -27,6 +27,10 @@ var BaseRoleGet = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Use before +role-update to inspect the current full permission config.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -26,6 +26,10 @@ var BaseRoleList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Returns role summaries; use +role-get for the full permission config.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
|
||||
@@ -26,7 +26,13 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true},
|
||||
{Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true},
|
||||
{Name: "json", Desc: "delta role config JSON; read lark-base-role-guide.md and role-config.md before changing permissions", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
"Requires advanced permissions to be enabled and the caller to be a Base admin.",
|
||||
"Update is a delta merge: only changed fields are updated, others remain unchanged.",
|
||||
"Use lark-base-role-guide.md as the entry guide and role-config.md as the role permission JSON SSOT.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
func jsonInputTip(flagName string) string {
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
|
||||
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; for complex JSON/DSL, read the lark-base reference and match the documented shape", flagName)
|
||||
}
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
|
||||
@@ -198,6 +198,25 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Risk != "high-risk-write" {
|
||||
continue
|
||||
}
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
flag := cmd.Flags().Lookup("yes")
|
||||
if flag == nil {
|
||||
t.Fatalf("%s missing --yes flag", shortcut.Command)
|
||||
}
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
if !strings.Contains(tips, "pass --yes without asking again") {
|
||||
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -251,20 +270,19 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
"requires keyword/search_fields",
|
||||
"optional select_fields/view_id/offset/limit",
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
"for keyword search only",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
`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>"]`,
|
||||
"Happy path fields: keyword (string), search_fields",
|
||||
"search_fields length 1-20",
|
||||
"limit range 1-200 defaults to 10",
|
||||
"view_id scopes search to records in that view",
|
||||
"Default output is markdown",
|
||||
"only for keyword search",
|
||||
"lark-base record read SOP",
|
||||
"inventing search JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -311,6 +329,401 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "dashboard list",
|
||||
shortcut: BaseDashboardList,
|
||||
wantTips: []string{
|
||||
"Use returned dashboard_id values",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard get",
|
||||
shortcut: BaseDashboardGet,
|
||||
wantTips: []string{
|
||||
"block-level details",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard create",
|
||||
shortcut: BaseDashboardCreate,
|
||||
wantTips: []string{
|
||||
"Record the returned dashboard_id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard update",
|
||||
shortcut: BaseDashboardUpdate,
|
||||
wantTips: []string{},
|
||||
},
|
||||
{
|
||||
name: "dashboard delete",
|
||||
shortcut: BaseDashboardDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
|
||||
"also deletes its blocks",
|
||||
"pass --yes",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard arrange",
|
||||
shortcut: BaseDashboardArrange,
|
||||
wantTips: []string{
|
||||
"not deterministic or position-specific",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block list",
|
||||
shortcut: BaseDashboardBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
|
||||
"Use returned block_id and type values",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block get",
|
||||
shortcut: BaseDashboardBlockGet,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
|
||||
"metadata such as name, type, layout, and data_config",
|
||||
"computed chart result",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block get data",
|
||||
shortcut: BaseDashboardBlockGetData,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
||||
"does not need --dashboard-id",
|
||||
"computed chart protocol JSON",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block create",
|
||||
shortcut: BaseDashboardBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
||||
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
|
||||
"+table-list and +field-list",
|
||||
"not table_id or field_id",
|
||||
"dashboard-block-data-config.md as the SSOT",
|
||||
"do not invent data_config from natural language",
|
||||
"sequentially",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block update",
|
||||
shortcut: BaseDashboardBlockUpdate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
||||
`--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
|
||||
"dashboard-block-data-config.md as the SSOT",
|
||||
"do not invent data_config from natural language",
|
||||
"Block type cannot be changed",
|
||||
"top-level keys",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dashboard block delete",
|
||||
shortcut: BaseDashboardBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
|
||||
"pass --yes",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "workflow list",
|
||||
shortcut: BaseWorkflowList,
|
||||
wantTips: []string{
|
||||
"workflow_id values with wkf prefix",
|
||||
"auto-paginates",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow get",
|
||||
shortcut: BaseWorkflowGet,
|
||||
wantTips: []string{
|
||||
"workflow-id must start with wkf",
|
||||
"steps may be an empty array",
|
||||
"Use +workflow-get before +workflow-update",
|
||||
"lark-base-workflow-schema.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow create",
|
||||
shortcut: BaseWorkflowCreate,
|
||||
wantTips: []string{
|
||||
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
|
||||
"client_token is required",
|
||||
"New workflows are created disabled",
|
||||
"+table-list and +field-list",
|
||||
"Step ids must be unique",
|
||||
"lark-base-workflow-guide.md as the entry guide",
|
||||
"lark-base-workflow-schema.md as the steps JSON SSOT",
|
||||
"do not invent steps[].type/data/next/children from natural language",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow update",
|
||||
shortcut: BaseWorkflowUpdate,
|
||||
wantTips: []string{
|
||||
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
|
||||
"PUT uses full replacement semantics",
|
||||
"Use +workflow-get first",
|
||||
"keep title/status/steps fields",
|
||||
"workflow-id must start with wkf",
|
||||
"Updating does not enable or disable",
|
||||
"do not invent steps[].type/data/next/children from natural language",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow enable",
|
||||
shortcut: BaseWorkflowEnable,
|
||||
wantTips: []string{
|
||||
"workflow-id must start with wkf",
|
||||
"does not modify steps",
|
||||
"New workflows are created disabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workflow disable",
|
||||
shortcut: BaseWorkflowDisable,
|
||||
wantTips: []string{
|
||||
"workflow-id must start with wkf",
|
||||
"does not delete the workflow or its steps",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantHelp []string
|
||||
}{
|
||||
{
|
||||
name: "table create fields",
|
||||
shortcut: BaseTableCreate,
|
||||
wantHelp: []string{
|
||||
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set filter",
|
||||
shortcut: BaseViewSetFilter,
|
||||
wantHelp: []string{
|
||||
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set sort",
|
||||
shortcut: BaseViewSetSort,
|
||||
wantHelp: []string{
|
||||
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
|
||||
`use {"sort_config":[]} to clear`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set group",
|
||||
shortcut: BaseViewSetGroup,
|
||||
wantHelp: []string{
|
||||
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set card",
|
||||
shortcut: BaseViewSetCard,
|
||||
wantHelp: []string{
|
||||
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set timebar",
|
||||
shortcut: BaseViewSetTimebar,
|
||||
wantHelp: []string{
|
||||
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view set visible fields",
|
||||
shortcut: BaseViewSetVisibleFields,
|
||||
wantHelp: []string{
|
||||
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question delete",
|
||||
shortcut: BaseFormQuestionsDelete,
|
||||
wantHelp: []string{
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
wantHelp: []string{
|
||||
`record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record upsert json",
|
||||
shortcut: BaseRecordUpsert,
|
||||
wantHelp: []string{
|
||||
`record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch create json",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantHelp: []string{
|
||||
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch update json",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantHelp: []string{
|
||||
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, want := range tt.wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "record upsert",
|
||||
shortcut: BaseRecordUpsert,
|
||||
wantTips: []string{
|
||||
"Happy path JSON is a top-level field map",
|
||||
"Without --record-id this creates a record",
|
||||
"does not auto-upsert by business key",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"do not write system fields, formula, lookup, or attachment fields",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
"datetime -> \"2026-03-24 10:00:00\"",
|
||||
"checkbox -> true/false",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
`location uses {"lng":116.397428,"lat":39.90923}`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates",
|
||||
"lark-base-cell-value.md",
|
||||
"do not invent values for fields not covered by the happy path",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: fields is the column order",
|
||||
"rows is an array of row arrays",
|
||||
"may use null for empty cells",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch create supports max 200 rows per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
"lark-base-cell-value.md",
|
||||
"do not invent values for fields not covered by the happy path",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: record_id_list is the target record IDs",
|
||||
"patch is a field map applied unchanged to every target record",
|
||||
"Do not use +record-batch-update for per-row different values",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch update supports max 200 records per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
"lark-base-cell-value.md",
|
||||
"do not invent values for fields not covered by the happy path",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
@@ -328,7 +741,7 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
|
||||
@@ -22,6 +22,9 @@ var BaseDashboardArrange = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
|
||||
},
|
||||
DryRun: dryRunDashboardArrange,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeDashboardArrange(runtime)
|
||||
|
||||
@@ -25,10 +25,19 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
|
||||
{Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
|
||||
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`,
|
||||
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
|
||||
"data_config uses table and field names, not table_id or field_id.",
|
||||
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
||||
"Record the returned block_id; block update/delete/get-data commands need it.",
|
||||
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
if runtime.Bool("no-validate") {
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseDashboardBlockDelete = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id").
|
||||
|
||||
@@ -24,6 +24,11 @@ var BaseDashboardBlockGet = common.Shortcut{
|
||||
blockIDFlag(true),
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
|
||||
"Use this command for block metadata such as name, type, layout, and data_config.",
|
||||
"Use +dashboard-block-get-data when you need the computed chart result instead of metadata.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" {
|
||||
|
||||
@@ -23,6 +23,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
|
||||
"This command does not need --dashboard-id.",
|
||||
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
|
||||
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
|
||||
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
|
||||
|
||||
@@ -21,9 +21,13 @@ var BaseDashboardBlockList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "page-size", Desc: "page size (max 100)"},
|
||||
{Name: "page-size", Desc: "page size, default 20, max 100"},
|
||||
{Name: "page-token", Desc: "pagination token"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
|
||||
"Use returned block_id and type values for +dashboard-block-get/update/delete/get-data.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||
|
||||
@@ -24,10 +24,18 @@ var BaseDashboardBlockUpdate = common.Shortcut{
|
||||
dashboardIDFlag(true),
|
||||
blockIDFlag(true),
|
||||
{Name: "name", Desc: "new block name"},
|
||||
{Name: "data-config", Desc: "data config JSON. For chart types: table_name, series|count_all, group_by, filter. For text type: text (markdown supported). See dashboard-block-data-config.md for details."},
|
||||
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
|
||||
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
|
||||
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
|
||||
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
|
||||
},
|
||||
Tips: []string{
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
|
||||
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
|
||||
"Read dashboard-block-data-config.md as the SSOT for data_config templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
|
||||
"Use +dashboard-block-get first to inspect the current data_config before replacing nested values.",
|
||||
"Block type cannot be changed; delete and recreate the block to change chart type.",
|
||||
"data_config update merges top-level keys, but each provided key is replaced as a whole.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pc := newParseCtx(runtime)
|
||||
if runtime.Bool("no-validate") {
|
||||
|
||||
@@ -20,7 +20,10 @@ var BaseDashboardCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "name", Desc: "dashboard name", Required: true},
|
||||
{Name: "theme-style", Desc: "theme style"},
|
||||
{Name: "theme-style", Desc: "theme style, defaults to platform default when omitted"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Record the returned dashboard_id; dashboard block create/get/update/delete/arrange commands need it.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -21,6 +21,11 @@ var BaseDashboardDelete = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
|
||||
"Deleting a dashboard also deletes its blocks and cannot be recovered.",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||
|
||||
@@ -21,6 +21,9 @@ var BaseDashboardGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Use +dashboard-block-list or +dashboard-block-get when you need block-level details.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id").
|
||||
|
||||
@@ -20,9 +20,12 @@ var BaseDashboardList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "page-size", Desc: "page size (max 100)"},
|
||||
{Name: "page-size", Desc: "page size, max 100"},
|
||||
{Name: "page-token", Desc: "pagination token"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use returned dashboard_id values for +dashboard-get, +dashboard-block-list, and +dashboard-block-create.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{}
|
||||
if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" {
|
||||
|
||||
@@ -21,7 +21,7 @@ var BaseDashboardUpdate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
dashboardIDFlag(true),
|
||||
{Name: "name", Desc: "new dashboard name"},
|
||||
{Name: "theme-style", Desc: "theme style"},
|
||||
{Name: "theme-style", Desc: "theme style, leave empty to keep current theme"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -23,7 +23,8 @@ var BaseFieldCreate = common.Shortcut{
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseFieldDelete = common.Shortcut{
|
||||
Scopes: []string{"base:field:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||
DryRun: dryRunFieldDelete,
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
`Example: lark-cli base +field-delete --base-token <base_token> --table-id <table_id> --field-id "Status" --yes`,
|
||||
},
|
||||
DryRun: dryRunFieldDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFieldDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -17,7 +17,12 @@ var BaseFieldGet = common.Shortcut{
|
||||
Scopes: []string{"base:field:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)},
|
||||
DryRun: dryRunFieldGet,
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-get --base-token <base_token> --table-id <table_id> --field-id "Status"`,
|
||||
"field-id accepts a field ID (fld...) or the field name from the current table.",
|
||||
"Returns full field configuration; use it as the baseline before +field-update.",
|
||||
},
|
||||
DryRun: dryRunFieldGet,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeFieldGet(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseFieldList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
DryRun: dryRunFieldList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -22,7 +22,11 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
fieldRefFlag(true),
|
||||
{Name: "keyword", Desc: "keyword for option query"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for fields with options, such as select or multi-select fields.",
|
||||
},
|
||||
DryRun: dryRunFieldSearchOptions,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -24,8 +24,9 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if err != nil || obj["name"] != "demo" {
|
||||
t.Fatalf("obj=%v err=%v", obj, err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
|
||||
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "match the documented shape") || strings.Contains(err.Error(), "array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
||||
@@ -66,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
|
||||
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
|
||||
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "complex JSON/DSL") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
|
||||
@@ -334,11 +334,11 @@ func TestJSONInputHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
|
||||
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "complex JSON/DSL") {
|
||||
t.Fatalf("syntaxErr=%v", syntaxErr)
|
||||
}
|
||||
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
|
||||
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "complex JSON/DSL") {
|
||||
t.Fatalf("typeErr=%v", typeErr)
|
||||
}
|
||||
}
|
||||
|
||||
6
shortcuts/base/high_risk.go
Normal file
6
shortcuts/base/high_risk.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
const baseHighRiskYesTip = "This is a high-risk write command. If the user explicitly requested it and the target is unambiguous, pass --yes without asking again."
|
||||
@@ -19,13 +19,14 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch create JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
|
||||
"Agent hint: use lark-base-cell-value.md as the source of truth for each CellValue.",
|
||||
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch create supports max 200 rows per call.",
|
||||
"Use the record-batch-create guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -19,13 +19,14 @@ var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "batch update JSON object", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
|
||||
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
|
||||
"Agent hint: use lark-base-cell-value.md as the source of truth for each patch CellValue.",
|
||||
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
|
||||
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -22,6 +22,10 @@ var BaseRecordDelete = common.Shortcut{
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
`Example: lark-cli base +record-delete --base-token <base_token> --table-id <table_id> --record-id <record_id_1> --record-id <record_id_2> --yes`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
|
||||
@@ -21,7 +21,11 @@ var BaseRecordHistoryList = common.Shortcut{
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "max-version", Type: "int", Desc: "max version for next page"},
|
||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"},
|
||||
{Name: "page-size", Type: "int", Default: "30", Desc: "pagination size, max 50"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-history-list --base-token <base_token> --table-id <table_id> --record-id <record_id>`,
|
||||
"This reads one record's history only; it is not a table-wide audit scan.",
|
||||
},
|
||||
DryRun: dryRunRecordHistoryList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -15,6 +15,13 @@ import (
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
|
||||
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
|
||||
}
|
||||
|
||||
type recordSelection struct {
|
||||
recordIDs []string
|
||||
selectFields []string
|
||||
|
||||
@@ -20,17 +20,15 @@ var BaseRecordSearch = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `record search JSON object; requires keyword/search_fields, optional select_fields/view_id/offset/limit`, Required: true},
|
||||
{Name: "json", Desc: `record search JSON object, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}; for keyword search only`, Required: true},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json '{"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"limit":50}'`,
|
||||
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"],"select_fields":["<field_id_or_name>"],"view_id":"<view_id_or_name>","offset":0,"limit":10}.`,
|
||||
`Happy path fields: keyword (string), search_fields (1-20 field names/ids), select_fields (optional projection, <=50), view_id (optional), offset (default 0), limit (default 10, range 1-200).`,
|
||||
"JSON constraints: keyword length >=1; search_fields length 1-20; select_fields length <=50; offset >=0 defaults to 0; limit range 1-200 defaults to 10.",
|
||||
"view_id scopes search to records in that view; when select_fields is omitted, returned fields follow that view's visible fields.",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use +record-search only for keyword search; use a filtered view plus +record-list for structured conditions.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing and limits.",
|
||||
"Use +record-search only for keyword search; for structured conditions, sorting, Top/Bottom N, or global conclusions, follow the lark-base record read SOP instead of inventing search JSON.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
|
||||
@@ -22,8 +22,9 @@ var BaseRecordShareLinkCreate = common.Shortcut{
|
||||
{Name: "record-ids", Type: "string_slice", Desc: "record IDs to generate share links for (comma-separated or repeatable, max 100)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Single record: --base-token xxx --table-id tblxxx --record-ids recxxx`,
|
||||
`Multiple records: --base-token xxx --table-id tblxxx --record-ids rec001,rec002,rec003`,
|
||||
`Example: lark-cli base +record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>`,
|
||||
"Max 100 record IDs per call; duplicate IDs are ignored.",
|
||||
"Output record_share_links maps record_id to URL; records without permission or missing records may be absent.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordShareBatch(runtime)
|
||||
|
||||
@@ -117,6 +117,7 @@ var BaseRecordRemoveAttachment = common.Shortcut{
|
||||
{Name: "file-token", Type: "string_array", Desc: "attachment file_token to remove from the target cell; repeat to remove multiple attachments; max 50 tokens", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
`Example: lark-cli base +record-remove-attachment --base-token <base_token> --table-id <table_id> --record-id <record_id> --field-id <attachment_field_id> --file-token <file_token> --yes`,
|
||||
`Repeat --file-token to remove multiple attachments from the same cell in one call.`,
|
||||
`This is a high-risk write command and requires --yes.`,
|
||||
|
||||
@@ -20,12 +20,14 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(false),
|
||||
{Name: "json", Desc: "record JSON object: Map<FieldNameOrID, CellValue>", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"Name":"Alice"}'`,
|
||||
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
|
||||
{Name: "json", Desc: `record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
|
||||
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Use the record-upsert guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordJSON(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseTableCreate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
{Name: "name", Desc: "table name", Required: true},
|
||||
{Name: "view", Desc: "view JSON object/array for create"},
|
||||
{Name: "fields", Desc: "field JSON array for create"},
|
||||
{Name: "fields", Desc: `field JSON array for create, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`},
|
||||
},
|
||||
Tips: []string{
|
||||
"Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.",
|
||||
"The first --fields item replaces the default field.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateTableCreate(runtime)
|
||||
|
||||
@@ -17,7 +17,12 @@ var BaseTableDelete = common.Shortcut{
|
||||
Scopes: []string{"base:table:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
||||
DryRun: dryRunTableDelete,
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +table-delete --base-token <base_token> --table-id "Old Tasks" --yes`,
|
||||
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
DryRun: dryRunTableDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseTableGet = common.Shortcut{
|
||||
Scopes: []string{"base:table:read", "base:field:read", "base:view:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)},
|
||||
DryRun: dryRunTableGet,
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +table-get --base-token <base_token> --table-id "Tasks"`,
|
||||
"table-id accepts a table ID (tbl...) or the table name in the current Base.",
|
||||
},
|
||||
DryRun: dryRunTableGet,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableGet(runtime)
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ var BaseTableList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"},
|
||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
||||
},
|
||||
DryRun: dryRunTableList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -19,11 +19,13 @@ var BaseViewCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: "view JSON object/array", Required: true},
|
||||
{Name: "json", Desc: "view JSON object/array; type defaults to grid; type range: grid, kanban, gallery, calendar, gantt", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Main","type":"grid"}'`,
|
||||
"Agent hint: use the lark-base skill's view-create guide for usage and limits.",
|
||||
`Example: lark-cli base +view-create --base-token <base_token> --table-id <table_id> --json '{"name":"Main","type":"grid"}'`,
|
||||
`Minimal: --json '{"name":"Main"}' creates a grid view.`,
|
||||
"Do not pass form as a view type; form views are managed through form commands.",
|
||||
`Use +view-set-visible-fields after creation when the user needs a specific field order or visibility.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewCreate(runtime)
|
||||
|
||||
@@ -17,7 +17,11 @@ var BaseViewDelete = common.Shortcut{
|
||||
Scopes: []string{"base:view:write_only"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)},
|
||||
DryRun: dryRunViewDelete,
|
||||
Tips: []string{
|
||||
baseHighRiskYesTip,
|
||||
`Example: lark-cli base +view-delete --base-token <base_token> --table-id <table_id> --view-id "Old View" --yes`,
|
||||
},
|
||||
DryRun: dryRunViewDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeViewDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ var BaseViewList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
DryRun: dryRunViewList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -20,11 +20,12 @@ var BaseViewSetCard = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "card JSON object", Required: true},
|
||||
{Name: "json", Desc: `card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"cover_field":"fldCover"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-card guide for usage and limits.",
|
||||
"Supported view types: gallery, kanban.",
|
||||
"cover_field should be an attachment field id/name, or null to clear.",
|
||||
"Use +view-get-card first when updating an existing card view configuration.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,10 +20,9 @@ var BaseViewSetFilter = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "filter JSON object", Required: true},
|
||||
{Name: "json", Desc: `filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"logic":"and","conditions":[["fldStatus","==","Todo"]]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-filter guide for usage and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -20,11 +20,13 @@ var BaseViewSetGroup = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "group JSON object", Required: true},
|
||||
{Name: "json", Desc: `group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}; use {"group_config":[]} to clear`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"group_config":[{"field":"fldStatus","desc":false}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
|
||||
"Supported view types: grid, kanban, gantt.",
|
||||
"Use a JSON object, not a bare array; grouping fields must be supported by the current view.",
|
||||
"group_config supports max 3 group items.",
|
||||
"Use +view-get-group first when modifying an existing grouping configuration.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,11 +20,13 @@ var BaseViewSetSort = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "sort_config JSON object", Required: true},
|
||||
{Name: "json", Desc: `sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}; use {"sort_config":[]} to clear; max 10 items`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"sort_config":[{"field":"fldPriority","desc":true}]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
|
||||
"Supported view types: grid, kanban, gallery, gantt.",
|
||||
"Use a JSON object, not a bare array; sorting fields must be supported by the current view.",
|
||||
"sort_config supports max 10 sort items.",
|
||||
"Use +view-get-sort first when modifying an existing sort configuration.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,11 +20,12 @@ var BaseViewSetTimebar = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: "timebar JSON object", Required: true},
|
||||
{Name: "json", Desc: `timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"start_time":"fldStart","end_time":"fldEnd","title":"fldTitle"}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-timebar guide for usage and limits.",
|
||||
"Supported view types: calendar, gantt.",
|
||||
"start_time, end_time, and title are required; use date/time fields for start_time and end_time.",
|
||||
"Use +view-get-timebar first when modifying an existing timebar configuration.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -20,11 +20,12 @@ var BaseViewSetVisibleFields = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
viewRefFlag(true),
|
||||
{Name: "json", Desc: `visible fields JSON object with "visible_fields"`, Required: true},
|
||||
{Name: "json", Desc: `visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"visible_fields":["fldXXX"]}'`,
|
||||
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
|
||||
"Supported view types: grid, kanban, gallery, calendar, gantt.",
|
||||
"Use a JSON object, not a bare array; primary field may be forced to the first position by the API.",
|
||||
"visible_fields controls both visibility and order; include every field that should remain visible.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateViewJSONObject(runtime)
|
||||
|
||||
@@ -19,7 +19,15 @@ var BaseWorkflowCreate = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}`, Required: true},
|
||||
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before constructing steps", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
|
||||
"client_token is required and should be unique per create request.",
|
||||
"New workflows are created disabled; call +workflow-enable after creation when the user wants it active.",
|
||||
"Before constructing steps, use +table-list and +field-list to confirm real table and field names.",
|
||||
"Step ids must be unique, and every next/children link must reference an existing step id.",
|
||||
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -21,6 +21,10 @@ var BaseWorkflowDisable = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
|
||||
"Disable only changes workflow state; it does not delete the workflow or its steps.",
|
||||
},
|
||||
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")
|
||||
|
||||
@@ -21,6 +21,11 @@ var BaseWorkflowEnable = common.Shortcut{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"workflow-id must start with wkf; do not pass a tbl table ID from the same URL.",
|
||||
"Enable only changes workflow state; it does not modify steps.",
|
||||
"New workflows are created disabled; enable after creation only when the user wants it active.",
|
||||
},
|
||||
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")
|
||||
|
||||
@@ -20,7 +20,13 @@ var BaseWorkflowGet = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
{Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "user-id-type", Desc: "user ID type for creator/updater fields, default open_id", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
},
|
||||
Tips: []string{
|
||||
"workflow-id must start with wkf; use +workflow-list if the ID is unknown.",
|
||||
"steps may be an empty array; that is valid for an unconfigured workflow.",
|
||||
"Use +workflow-get before +workflow-update, then edit the returned definition and keep fields you do not intend to change.",
|
||||
"Read lark-base-workflow-schema.md when interpreting or reusing returned steps.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -20,7 +20,11 @@ var BaseWorkflowList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}},
|
||||
{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"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Returns workflow_id values with wkf prefix; pass those IDs to +workflow-get/enable/disable/update.",
|
||||
"This shortcut auto-paginates and returns all matched workflows.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -20,7 +20,16 @@ var BaseWorkflowUpdate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "base-token", Desc: "base token", Required: true},
|
||||
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
|
||||
{Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}`, Required: true},
|
||||
{Name: "json", Desc: "workflow body JSON; read lark-base-workflow-guide.md and lark-base-workflow-schema.md before replacing steps", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
|
||||
"PUT uses full replacement semantics; omitting steps clears the existing workflow steps.",
|
||||
"Use +workflow-get first, then edit the returned definition and keep title/status/steps fields you do not intend to change.",
|
||||
"workflow-id must start with wkf; do not pass a tbl table ID.",
|
||||
"Step ids must be unique, and every next/children link must reference an existing step id.",
|
||||
"Updating does not enable or disable a workflow; call +workflow-enable or +workflow-disable separately.",
|
||||
"Use lark-base-workflow-guide.md as the entry guide and lark-base-workflow-schema.md as the steps JSON SSOT; do not invent steps[].type/data/next/children from natural language.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
|
||||
@@ -860,9 +860,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
if s.HasFormat {
|
||||
rctx.Format = rctx.Str("format")
|
||||
}
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
}
|
||||
@@ -1026,17 +1024,15 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
|
||||
cmd.Flags().Bool("dry-run", false, "print request without executing")
|
||||
if s.HasFormat {
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
if s.HasFormat {
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
39
shortcuts/common/runner_format_universal_test.go
Normal file
39
shortcuts/common/runner_format_universal_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestShortcutMount_FormatFlagAlwaysRegistered verifies that --format is
|
||||
// injected for every shortcut regardless of the HasFormat field value.
|
||||
func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "im",
|
||||
Command: "+message-send",
|
||||
Description: "send message",
|
||||
HasFormat: false, // explicitly false — format must still be registered
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+message-send"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
flag := cmd.Flags().Lookup("format")
|
||||
if flag == nil {
|
||||
t.Fatal("--format flag not registered; expected it to be injected even when HasFormat is false")
|
||||
}
|
||||
if flag.DefValue != "json" {
|
||||
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ type Shortcut struct {
|
||||
// Declarative fields (new framework).
|
||||
AuthTypes []string // supported identities: "user", "bot" (default: ["user"])
|
||||
Flags []Flag // flag definitions; --dry-run is auto-injected
|
||||
HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv)
|
||||
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
|
||||
|
||||
@@ -102,9 +102,6 @@ func TestResolveMarkdownAsPost(t *testing.T) {
|
||||
if !strings.Contains(got, `"tag":"md"`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want post payload", got)
|
||||
}
|
||||
if !strings.Contains(got, `"tag":"text"`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want segmented blank-line text paragraph", got)
|
||||
}
|
||||
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
|
||||
}
|
||||
|
||||
@@ -817,49 +817,25 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
|
||||
// 5. Compress excess blank lines
|
||||
// 6. Strip invalid image references (keep only img_xxx keys)
|
||||
var (
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`)
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
)
|
||||
|
||||
const (
|
||||
markdownCodeBlockPlaceholder = "___CB_"
|
||||
postBlankLinePlaceholder = "\u200B"
|
||||
)
|
||||
|
||||
type markdownPart struct {
|
||||
text string
|
||||
newlineCount int
|
||||
isSeparator bool
|
||||
}
|
||||
|
||||
func protectMarkdownCodeBlocks(text string) (string, []string) {
|
||||
var codeBlocks []string
|
||||
protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
idx := len(codeBlocks)
|
||||
codeBlocks = append(codeBlocks, m)
|
||||
return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)
|
||||
})
|
||||
return protected, codeBlocks
|
||||
}
|
||||
|
||||
func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string {
|
||||
restored := text
|
||||
for i, block := range codeBlocks {
|
||||
restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, i), block, 1)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func optimizeMarkdownStyle(text string) string {
|
||||
r, codeBlocks := protectMarkdownCodeBlocks(text)
|
||||
const mark = "___CB_"
|
||||
var codeBlocks []string
|
||||
r := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
|
||||
idx := len(codeBlocks)
|
||||
codeBlocks = append(codeBlocks, m)
|
||||
return fmt.Sprintf("%s%d___", mark, idx)
|
||||
})
|
||||
|
||||
// Only downgrade when original text has H1~H3; order matters (H2~H6 first).
|
||||
if reHasH1toH3.MatchString(text) {
|
||||
@@ -872,7 +848,9 @@ func optimizeMarkdownStyle(text string) string {
|
||||
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
|
||||
r = reTableAfter.ReplaceAllString(r, "$1\n")
|
||||
|
||||
r = restoreMarkdownCodeBlocks(r, codeBlocks)
|
||||
for i, block := range codeBlocks {
|
||||
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
|
||||
}
|
||||
|
||||
r = reExcessNL.ReplaceAllString(r, "\n\n")
|
||||
|
||||
@@ -891,109 +869,12 @@ func optimizeMarkdownStyle(text string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
func shouldUseSegmentedPost(markdown string) bool {
|
||||
protected, _ := protectMarkdownCodeBlocks(markdown)
|
||||
return reBlankLineSeparator.MatchString(protected)
|
||||
}
|
||||
|
||||
func splitMarkdownByBlankLines(markdown string) []markdownPart {
|
||||
protected, codeBlocks := protectMarkdownCodeBlocks(markdown)
|
||||
locs := reBlankLineSeparator.FindAllStringIndex(protected, -1)
|
||||
if len(locs) == 0 {
|
||||
return []markdownPart{{text: markdown}}
|
||||
}
|
||||
|
||||
parts := make([]markdownPart, 0, len(locs)*2+1)
|
||||
last := 0
|
||||
for _, loc := range locs {
|
||||
if loc[0] > last {
|
||||
content := restoreMarkdownCodeBlocks(protected[last:loc[0]], codeBlocks)
|
||||
if content != "" {
|
||||
parts = append(parts, markdownPart{text: content})
|
||||
}
|
||||
}
|
||||
separator := protected[loc[0]:loc[1]]
|
||||
parts = append(parts, markdownPart{
|
||||
isSeparator: true,
|
||||
newlineCount: strings.Count(separator, "\n"),
|
||||
})
|
||||
last = loc[1]
|
||||
}
|
||||
|
||||
if last < len(protected) {
|
||||
content := restoreMarkdownCodeBlocks(protected[last:], codeBlocks)
|
||||
if content != "" {
|
||||
parts = append(parts, markdownPart{text: content})
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return []markdownPart{{text: markdown}}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func marshalMarkdownPostContent(content [][]map[string]interface{}) string {
|
||||
payload := map[string]interface{}{
|
||||
"zh_cn": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func buildSingleMDPost(markdown string) string {
|
||||
return marshalMarkdownPostContent([][]map[string]interface{}{
|
||||
{{
|
||||
"tag": "md",
|
||||
"text": optimizeMarkdownStyle(markdown),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func buildSegmentedPost(markdown string) string {
|
||||
parts := splitMarkdownByBlankLines(markdown)
|
||||
content := make([][]map[string]interface{}, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part.isSeparator {
|
||||
for i := 1; i < part.newlineCount; i++ {
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "text",
|
||||
"text": postBlankLinePlaceholder,
|
||||
}})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if part.text == "" {
|
||||
continue
|
||||
}
|
||||
optimized := strings.Trim(optimizeMarkdownStyle(part.text), "\n")
|
||||
if optimized == "" {
|
||||
continue
|
||||
}
|
||||
content = append(content, []map[string]interface{}{{
|
||||
"tag": "md",
|
||||
"text": optimized,
|
||||
}})
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
return marshalMarkdownPostContent(content)
|
||||
}
|
||||
|
||||
func buildMarkdownPostContent(markdown string) string {
|
||||
if shouldUseSegmentedPost(markdown) {
|
||||
return buildSegmentedPost(markdown)
|
||||
}
|
||||
return buildSingleMDPost(markdown)
|
||||
}
|
||||
|
||||
// wrapMarkdownAsPost wraps markdown text into Feishu post format JSON (no network).
|
||||
// Used by DryRun. Output may include md/text paragraphs when blank-line separators are present.
|
||||
// Used by DryRun. Output: {"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||
func wrapMarkdownAsPost(markdown string) string {
|
||||
return buildMarkdownPostContent(markdown)
|
||||
optimized := optimizeMarkdownStyle(markdown)
|
||||
inner, _ := json.Marshal(optimized)
|
||||
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||
}
|
||||
|
||||
var reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\((https?://[^)\s]+)\)`)
|
||||
@@ -1028,7 +909,9 @@ func wrapMarkdownAsPostForDryRun(markdown string) (content, desc string) {
|
||||
// and wraps as post format JSON. Used by Execute (makes network calls).
|
||||
func resolveMarkdownAsPost(ctx context.Context, runtime *common.RuntimeContext, markdown string) string {
|
||||
resolved := resolveMarkdownImageURLs(ctx, runtime, markdown)
|
||||
return buildMarkdownPostContent(resolved)
|
||||
optimized := optimizeMarkdownStyle(resolved)
|
||||
inner, _ := json.Marshal(optimized)
|
||||
return `{"zh_cn":{"content":[[{"tag":"md","text":` + string(inner) + `}]]}}`
|
||||
}
|
||||
|
||||
// resolveMarkdownImageURLs finds  in markdown, downloads each URL,
|
||||
|
||||
@@ -6,7 +6,6 @@ package im
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -17,36 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func decodePostContentForTest(t *testing.T, raw string) []interface{} {
|
||||
t.Helper()
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v, raw=%s", err, raw)
|
||||
}
|
||||
locale, _ := payload["zh_cn"].(map[string]interface{})
|
||||
content, _ := locale["content"].([]interface{})
|
||||
if content == nil {
|
||||
t.Fatalf("post content missing: %#v", payload)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func decodePostParagraphForTest(t *testing.T, raw string, idx int) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
content := decodePostContentForTest(t, raw)
|
||||
if idx >= len(content) {
|
||||
t.Fatalf("paragraph index %d out of range, len=%d, raw=%s", idx, len(content), raw)
|
||||
}
|
||||
paragraph, _ := content[idx].([]interface{})
|
||||
if len(paragraph) != 1 {
|
||||
t.Fatalf("paragraph %d = %#v, want single node", idx, paragraph)
|
||||
}
|
||||
node, _ := paragraph[0].(map[string]interface{})
|
||||
return node
|
||||
}
|
||||
|
||||
func TestNormalizeAtMentions(t *testing.T) {
|
||||
input := `<at id=ou_alpha/> hi <at open_id="ou_beta"> and <at user_id=ou_gamma /> and <at email="x@example.com"/>`
|
||||
got := normalizeAtMentions(input)
|
||||
@@ -171,16 +140,6 @@ func TestWrapMarkdownAsPostForDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPostForDryRun_SegmentedBlankLines(t *testing.T) {
|
||||
content, _ := wrapMarkdownAsPostForDryRun("hello\n\n")
|
||||
if !strings.Contains(content, ``) {
|
||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want placeholder img key", content)
|
||||
}
|
||||
if !strings.Contains(content, `"tag":"text"`) {
|
||||
t.Fatalf("wrapMarkdownAsPostForDryRun(segmented) content = %q, want blank-line text paragraph", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaContentWithoutUploads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -375,88 +334,15 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
|
||||
func TestWrapMarkdownAsPost(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("hello **world**")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("wrapMarkdownAsPost() content len = %d, want 1", len(content))
|
||||
// Should produce valid JSON with post structure
|
||||
if !strings.Contains(got, `"tag":"md"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing md tag: %s", got)
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 0)
|
||||
if node["tag"] != "md" {
|
||||
t.Fatalf("wrapMarkdownAsPost() tag = %#v, want md", node["tag"])
|
||||
if !strings.Contains(got, `"zh_cn"`) {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing zh_cn: %s", got)
|
||||
}
|
||||
if node["text"] != "hello **world**" {
|
||||
t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSegmentedPost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want bool
|
||||
}{
|
||||
{name: "single newline", markdown: "a\nb", want: false},
|
||||
{name: "blank line", markdown: "a\n\nb", want: true},
|
||||
{name: "blank line with spaces", markdown: "a\n \nb", want: true},
|
||||
{name: "multiple blank lines", markdown: "a\n \n \n b", want: true},
|
||||
{name: "blank lines inside code block only", markdown: "```go\n\n\nfmt.Println(1)\n```\nnext", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldUseSegmentedPost(tt.markdown); got != tt.want {
|
||||
t.Fatalf("shouldUseSegmentedPost(%q) = %v, want %v", tt.markdown, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedBlankLines(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n\nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\nb) content len = %d, want 3", len(content))
|
||||
}
|
||||
|
||||
first := decodePostParagraphForTest(t, got, 0)
|
||||
if first["tag"] != "md" || first["text"] != "a" {
|
||||
t.Fatalf("first paragraph = %#v, want md/a", first)
|
||||
}
|
||||
|
||||
second := decodePostParagraphForTest(t, got, 1)
|
||||
if second["tag"] != "text" || second["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("second paragraph = %#v, want blank text placeholder", second)
|
||||
}
|
||||
|
||||
third := decodePostParagraphForTest(t, got, 2)
|
||||
if third["tag"] != "md" || third["text"] != "b" {
|
||||
t.Fatalf("third paragraph = %#v, want md/b", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedMultipleBlankLines(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n\n\nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 4 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n\\n\\nb) content len = %d, want 4", len(content))
|
||||
}
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
node := decodePostParagraphForTest(t, got, i)
|
||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("blank paragraph %d = %#v, want blank text placeholder", i, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapMarkdownAsPost_SegmentedBlankLinesWithSpaces(t *testing.T) {
|
||||
got := wrapMarkdownAsPost("a\n \nb")
|
||||
content := decodePostContentForTest(t, got)
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("wrapMarkdownAsPost(a\\n \\nb) content len = %d, want 3", len(content))
|
||||
}
|
||||
node := decodePostParagraphForTest(t, got, 1)
|
||||
if node["tag"] != "text" || node["text"] != postBlankLinePlaceholder {
|
||||
t.Fatalf("middle paragraph = %#v, want blank text placeholder", node)
|
||||
if !strings.Contains(got, "hello **world**") {
|
||||
t.Fatalf("wrapMarkdownAsPost() missing content: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,11 @@ func TestValidateProxyAddr(t *testing.T) {
|
||||
"http://gateway.docker.internal:16384",
|
||||
// trailing slash is tolerated
|
||||
"http://127.0.0.1:8080/",
|
||||
// https: any valid host (including remote, cross-machine) is allowed
|
||||
"https://127.0.0.1:16384",
|
||||
"https://sidecar.mycorp.com",
|
||||
"https://sidecar.mycorp.com:8443",
|
||||
"https://sidecar.corp.internal:443/",
|
||||
}
|
||||
for _, addr := range valid {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
@@ -242,6 +247,8 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
"http://user@127.0.0.1:16384",
|
||||
"http://user:pass@127.0.0.1:16384",
|
||||
"http://127.0.0.1@attacker.com:16384",
|
||||
"https://x@evil.com",
|
||||
"https://user:pass@sidecar.mycorp.com",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
@@ -259,23 +266,99 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is
|
||||
// rejected explicitly (not lumped into a generic "bad scheme" error) because
|
||||
// the interceptor hardcodes http and would silently downgrade an https URL
|
||||
// otherwise. The message must mention https so users understand why their
|
||||
// perfectly-looking config is refused.
|
||||
func TestValidateProxyAddr_HTTPSRejected(t *testing.T) {
|
||||
// TestValidateProxyAddr_HTTPSAllowed pins the contract: https addresses are
|
||||
// accepted, including a remote sidecar on another machine. TLS provides
|
||||
// confidentiality over the network and the HMAC signature provides
|
||||
// integrity/auth, so cross-machine https is supported.
|
||||
func TestValidateProxyAddr_HTTPSAllowed(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://127.0.0.1:16384",
|
||||
"https://127.0.0.1:16384", // same-host over TLS
|
||||
"https://sidecar.corp.internal:443",
|
||||
"https://sidecar.mycorp.com", // remote, no explicit port
|
||||
"https://sidecar.mycorp.com:8443",
|
||||
} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_HTTPRemoteRejected: plaintext http to a non-same-host
|
||||
// address stays rejected — a remote sidecar must use https.
|
||||
func TestValidateProxyAddr_HTTPRemoteRejected(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"http://sidecar.mycorp.com",
|
||||
"http://sidecar.mycorp.com:8080",
|
||||
"http://10.0.0.1:16384",
|
||||
} {
|
||||
err := ValidateProxyAddr(addr)
|
||||
if err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "https") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "https") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
|
||||
t.Errorf("ValidateProxyAddr(%q): error should point to https/same-host, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyScheme: scheme is https only for https:// addresses, http otherwise.
|
||||
// Case-insensitive: HTTPS:// must resolve to https, otherwise a remote sidecar
|
||||
// would silently downgrade to plaintext http (see ProxyScheme doc).
|
||||
func TestProxyScheme(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://sidecar.mycorp.com": "https",
|
||||
"https://127.0.0.1:16384": "https",
|
||||
"http://127.0.0.1:16384": "http",
|
||||
"127.0.0.1:16384": "http",
|
||||
// case-insensitive scheme
|
||||
"HTTPS://sidecar.mycorp.com": "https",
|
||||
"Https://sidecar.mycorp.com": "https",
|
||||
"HtTp://127.0.0.1:16384": "http",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := ProxyScheme(in); got != want {
|
||||
t.Errorf("ProxyScheme(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_SchemeCaseInsensitive: mixed-case scheme must follow the
|
||||
// same policy as lower-case — HTTPS accepted (remote allowed), HTTP remote
|
||||
// rejected — so case can't be used to bypass the plaintext same-host rule.
|
||||
func TestValidateProxyAddr_SchemeCaseInsensitive(t *testing.T) {
|
||||
for _, addr := range []string{"HTTPS://sidecar.mycorp.com", "Https://sidecar.corp.internal:443"} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
for _, addr := range []string{"HtTp://sidecar.mycorp.com", "HTTP://10.0.0.1:16384"} {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_IPv6HTTPS pins IPv6 https forms.
|
||||
func TestValidateProxyAddr_IPv6HTTPS(t *testing.T) {
|
||||
for _, addr := range []string{"https://[::1]:443", "https://[::1]"} {
|
||||
if err := ValidateProxyAddr(addr); err != nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateProxyAddr_RejectsQueryFragment: a proxy address must not carry a
|
||||
// query or fragment, for either scheme.
|
||||
func TestValidateProxyAddr_RejectsQueryFragment(t *testing.T) {
|
||||
for _, addr := range []string{
|
||||
"https://sidecar.mycorp.com?x=1",
|
||||
"https://sidecar.mycorp.com#frag",
|
||||
"http://127.0.0.1:16384?x=1",
|
||||
} {
|
||||
if err := ValidateProxyAddr(addr); err == nil {
|
||||
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,6 +372,10 @@ func TestProxyHost(t *testing.T) {
|
||||
{"http://0.0.0.0:8080", "0.0.0.0:8080"},
|
||||
{"http://host.docker.internal:16384/", "host.docker.internal:16384"},
|
||||
{"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme
|
||||
// https forms (remote sidecar)
|
||||
{"https://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||
{"https://sidecar.mycorp.com:8443/", "sidecar.mycorp.com:8443"},
|
||||
{"HTTPS://sidecar.mycorp.com", "sidecar.mycorp.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
// Package sidecar defines the wire protocol shared between the CLI client
|
||||
// (running inside a sandbox) and the auth sidecar proxy (running in a
|
||||
// trusted environment). Communication uses plain HTTP.
|
||||
// trusted environment). Communication uses HTTP for a same-host sidecar, or
|
||||
// HTTPS (TLS) for a remote sidecar.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
@@ -103,32 +104,31 @@ func isSameHost(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// errNotSameHost is the shared error returned when the sidecar address does
|
||||
// not resolve to the same physical host as the sandbox. Kept in one place so
|
||||
// tests can look for a stable marker.
|
||||
// errNotSameHost is the shared error returned when a plaintext (http) sidecar
|
||||
// address does not resolve to the same physical host as the sandbox. Kept in
|
||||
// one place so tests can look for a stable marker.
|
||||
func errNotSameHost(addr string) error {
|
||||
return fmt.Errorf("invalid proxy address %q: host must be loopback "+
|
||||
"(127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
return fmt.Errorf("invalid proxy address %q: a plaintext (http) sidecar must be "+
|
||||
"loopback (127.0.0.1 / ::1) or a recognized same-host alias "+
|
||||
"(localhost, host.docker.internal, host.containers.internal, "+
|
||||
"host.lima.internal, gateway.docker.internal). "+
|
||||
"The sidecar must run on the same physical machine as the sandbox — "+
|
||||
"cross-machine deployment is not a sidecar and is not supported", addr)
|
||||
"For a remote sidecar on another machine, use an https:// address instead", addr)
|
||||
}
|
||||
|
||||
// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value.
|
||||
// Accepted formats:
|
||||
// - http://host:port
|
||||
// - host:port (bare address, treated as http)
|
||||
// - https://host[:port] (remote sidecar; cross-machine allowed)
|
||||
// - http://host:port (plaintext; same-host only)
|
||||
// - host:port (bare address, treated as plaintext http; same-host only)
|
||||
//
|
||||
// Host must be loopback or in sameHostAliases. The sidecar pattern is
|
||||
// inherently same-machine; cross-machine deployment is a different product
|
||||
// and is not supported by this feature.
|
||||
//
|
||||
// https:// is rejected because sidecar is a same-host pattern: loopback
|
||||
// and virtual same-host bridges don't traverse any untrusted medium, so
|
||||
// TLS adds no security. Cross-machine deployment is out of scope (see the
|
||||
// host constraint above), so there is no scenario today where https
|
||||
// provides a real benefit over http on loopback.
|
||||
// Scheme policy:
|
||||
// - https:// — any valid host is allowed, including a remote central sidecar
|
||||
// on another machine. TLS provides confidentiality over the untrusted
|
||||
// network; the per-request HMAC signature provides integrity/auth.
|
||||
// - http:// (or bare host:port) — plaintext, allowed only when the host is
|
||||
// loopback (127.0.0.1 / ::1) or a recognized same-host alias (a virtual
|
||||
// same-host bridge that stays on the physical machine). For a remote
|
||||
// sidecar, use an https:// address instead.
|
||||
//
|
||||
// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol
|
||||
// does not use basic auth, and the syntactic slot exists only as a phishing
|
||||
@@ -140,11 +140,11 @@ func ValidateProxyAddr(addr string) error {
|
||||
return fmt.Errorf("proxy address is empty")
|
||||
}
|
||||
|
||||
// Bare host:port (no scheme) — validate as a net address.
|
||||
// Bare host:port (no scheme) — treated as plaintext http, so same-host only.
|
||||
if !strings.Contains(addr, "://") {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr)
|
||||
return fmt.Errorf("invalid proxy address %q: expected host:port or http(s)://host[:port]", addr)
|
||||
}
|
||||
if host == "" || port == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr)
|
||||
@@ -159,33 +159,47 @@ func ValidateProxyAddr(addr string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: %w", addr, err)
|
||||
}
|
||||
// userinfo (user:pass@) is rejected unconditionally (phishing vector).
|
||||
if u.User != nil {
|
||||
return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr)
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+
|
||||
"same-host only (loopback or virtual same-host bridge), so TLS adds "+
|
||||
"no security; cross-machine deployment is out of scope", addr)
|
||||
}
|
||||
if u.Scheme != "http" {
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http", addr)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("invalid proxy address %q: missing host", addr)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return fmt.Errorf("invalid proxy address %q: path is not allowed", addr)
|
||||
}
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
if u.RawQuery != "" {
|
||||
return fmt.Errorf("invalid proxy address %q: query is not allowed", addr)
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
return fmt.Errorf("invalid proxy address %q: fragment is not allowed", addr)
|
||||
}
|
||||
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
// Remote sidecar over TLS. Cross-machine is allowed: https provides
|
||||
// confidentiality over the network and the per-request HMAC signature
|
||||
// provides integrity/authentication, so a remote central sidecar is
|
||||
// supported without exposing credentials or signing material in clear.
|
||||
return nil
|
||||
case "http":
|
||||
// Plaintext: only safe on the same physical host (loopback or a virtual
|
||||
// same-host bridge). For a remote sidecar use an https:// address.
|
||||
// u.Hostname() strips the port and unwraps IPv6 brackets.
|
||||
if !isSameHost(u.Hostname()) {
|
||||
return errNotSameHost(addr)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid proxy address %q: scheme must be http or https", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyHost extracts the host:port from an AUTH_PROXY URL.
|
||||
// Input is expected to be an HTTP URL like "http://127.0.0.1:16384".
|
||||
// Returns the host:port portion for URL rewriting.
|
||||
// Input is expected to be an http:// or https:// URL like
|
||||
// "http://127.0.0.1:16384" or "https://sidecar.mycorp.com".
|
||||
// Returns the host[:port] portion for URL rewriting.
|
||||
func ProxyHost(authProxy string) string {
|
||||
// Strip scheme
|
||||
host := authProxy
|
||||
@@ -196,3 +210,19 @@ func ProxyHost(authProxy string) string {
|
||||
host = strings.TrimRight(host, "/")
|
||||
return host
|
||||
}
|
||||
|
||||
// ProxyScheme returns the URL scheme the CLI must use when routing to the
|
||||
// sidecar: "https" for a TLS (remote) sidecar, otherwise "http" (same-host
|
||||
// plaintext). Input is a value already accepted by ValidateProxyAddr.
|
||||
//
|
||||
// It parses the address (rather than a case-sensitive prefix check) so the
|
||||
// result stays consistent with ValidateProxyAddr, which relies on url.Parse
|
||||
// normalizing the scheme. Otherwise "HTTPS://host" — accepted as https by
|
||||
// ValidateProxyAddr — would silently downgrade to plaintext http here,
|
||||
// breaking the "remote must use TLS" boundary.
|
||||
func ProxyScheme(authProxy string) string {
|
||||
if u, err := url.Parse(authProxy); err == nil && strings.EqualFold(u.Scheme, "https") {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
@@ -114,18 +114,23 @@ export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to o
|
||||
|
||||
**`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup:
|
||||
|
||||
- Scheme must be `http://` (or bare `host:port`). `https://` is rejected
|
||||
today because the interceptor does not yet perform TLS; a future PR that
|
||||
wires up real TLS will relax this.
|
||||
- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized
|
||||
same-host aliases: `localhost`, `host.docker.internal`,
|
||||
`host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`.
|
||||
The sidecar pattern is inherently same-machine; cross-machine deployment
|
||||
is a different product (auth broker / STS) with different security
|
||||
requirements (mTLS, cert rotation, per-client keys) and is not supported
|
||||
by this feature.
|
||||
- Scheme must be `http://` / `https://` (or bare `host:port`, treated as
|
||||
plaintext http).
|
||||
- `https://<any-host>` is allowed, **including a remote sidecar on another
|
||||
machine**: TLS provides confidentiality over the network and the
|
||||
per-request HMAC signature provides integrity/authentication.
|
||||
- Plaintext `http://` (and bare `host:port`) is allowed **only same-host**:
|
||||
loopback (`127.0.0.1`, `::1`) or a recognized same-host alias
|
||||
(`localhost`, `host.docker.internal`, `host.containers.internal`,
|
||||
`host.lima.internal`, `gateway.docker.internal`). For a remote sidecar,
|
||||
use an `https://` address.
|
||||
- No path, query, fragment, or `user:pass@` in the URL.
|
||||
|
||||
> Note: this demo server itself terminates plain HTTP and is meant to run
|
||||
> locally. A production **remote** sidecar must terminate TLS (its own
|
||||
> `https://` endpoint, e.g. behind a load balancer or with a real
|
||||
> certificate); the CLI-side policy above is what enables pointing at it.
|
||||
|
||||
**How auto identity detection works in sidecar mode**: on every invocation the
|
||||
CLI asks the sidecar to look up the logged-in user's `open_id` via
|
||||
`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- `lark-cli base records create` ❌
|
||||
2. **优先使用 Shortcut** — 有 Shortcut 的操作不要手拼原生 API
|
||||
3. **写记录前** — 先调用 `table.fields list` 获取字段 `type/ui_type`,再读 [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md);该文档是 CellValue 的 source of truth
|
||||
4. **写字段前** — 先读 [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) 确认字段类型 JSON 结构
|
||||
4. **写字段前** — 先读 [lark-base-field-json.md](../../skills/lark-base/references/lark-base-field-json.md) 确认字段类型 JSON 结构
|
||||
5. **筛选查询前** — 先读 [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md),当前 `base/v3` 通过 `view.filter update + table.records list` 组合完成筛选读取
|
||||
6. **批量上限 200 条/次** — 同一表建议串行写入,并在批次间延迟 0.5–1 秒
|
||||
7. **改名和删除按明确意图执行** — 视图重命名这类低风险改名操作,目标和新名称明确时可直接执行;删除记录 / 字段 / 表时,只要用户已经明确要求删除且目标明确,也可直接执行,不需要再补一次确认
|
||||
@@ -113,7 +113,7 @@ lark-cli wiki spaces.get_node --params '{"token":"Pgrrwvr***********UnRb"}'
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [lark-base-shortcut-field-properties.md](../../skills/lark-base/references/lark-base-shortcut-field-properties.md) — 字段类型 JSON 配置
|
||||
- [lark-base-field-json.md](../../skills/lark-base/references/lark-base-field-json.md) — 字段类型 JSON 配置
|
||||
- [lark-base-cell-value.md](../../skills/lark-base/references/lark-base-cell-value.md) — CellValue source of truth
|
||||
- [lark-base-view-set-filter.md](../../skills/lark-base/references/lark-base-view-set-filter.md) — 查询筛选指南(filter / operator / sort / 分页)
|
||||
- [examples.md](../../skills/lark-base/references/examples.md) — 完整操作示例(建表、筛选、更新)
|
||||
- 具体命令示例由命令 --help 内置 tips 承接;复杂 JSON 只读上方保留 reference
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.2
|
||||
description: "当需要用 lark-cli 操作飞书多维表格(Base)时调用:搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询,以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,353 +10,151 @@ metadata:
|
||||
|
||||
# base
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
> **执行前必做:** 执行任何 `base` 命令前,必须先阅读对应命令的 reference 文档,再调用命令。
|
||||
> **查询类任务必做:** 涉及筛选、排序、Top/Bottom N、聚合、多表关联、查询后写入或判断全局结论时,必须先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),再选择 `record / view / data-query` 路径。
|
||||
> **命名约定:** Base 业务命令仅使用 `lark-cli base +...` 形式;解析 Wiki 链接使用 `lark-cli wiki +node-get`。
|
||||
> **分流规则:** 如果用户要“把本地文件导入成 Base / 多维表格 / bitable”,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
## 何时使用
|
||||
|
||||
## 1. 何时使用本 Skill
|
||||
使用本 skill:
|
||||
|
||||
### 1.1 触发条件
|
||||
- 用户明确提到 Base / 多维表格 / bitable,或给出 `/base/` 链接。
|
||||
- 用户要在 Base 内建表、改表、管理字段、写记录、查记录、配视图。
|
||||
- 用户要在 Base 内做公式字段、lookup 字段、跨表计算、派生指标、筛选聚合、TopN、统计分析。
|
||||
- 用户要管理 Base 表单、仪表盘、workflow、高级权限或角色。
|
||||
- 用户要把旧 Base 聚合式命令或旧写法迁移到当前 `lark-cli base +...` shortcut。
|
||||
|
||||
以下场景应使用本 skill:
|
||||
不要使用本 skill:
|
||||
|
||||
- 用户明确要操作飞书多维表格 / Base。
|
||||
- 用户要建表、改表、查表、删表,或管理字段、记录、视图。
|
||||
- 用户要做公式字段、lookup 字段、派生指标、跨表计算。
|
||||
- 用户要做临时统计、聚合分析、比较排序、求最值。
|
||||
- 用户要管理 workflow、dashboard、表单、角色权限。
|
||||
- 用户给出 `/base/{token}` 链接。
|
||||
- 用户给出 `/wiki/{token}` 链接,且最终解析为 `bitable`。
|
||||
- 用户要把旧的 Base 聚合式写法改成当前原子命令写法,例如把旧 `+table / +field / +record / +view / +history / +workspace` 改写成当前命令。
|
||||
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`。
|
||||
- 把本地 Excel / CSV / `.base` 导入成 Base,转 `lark-drive +import --type bitable`。
|
||||
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
|
||||
|
||||
以下场景不应使用本 skill:
|
||||
## 使用边界
|
||||
|
||||
- 用户只是做认证、初始化配置、切换 `--as user/bot`、处理 scope。此时先读 `../lark-shared/SKILL.md`。
|
||||
- 用户只是泛化地讨论“数据分析 / 字段设计”,但并不在 Base 场景中。不要因为提到“统计 / 公式 / lookup”就误触发。
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL;简单命令由命令自身的参数、tips 和错误恢复承接。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 用户只给 Base 名称或关键词时,先用 `lark-cli drive +search --query <keyword> --doc-types bitable` 定位资源。
|
||||
- Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "<base title>" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
### 1.2 前置约束
|
||||
## 快速路由
|
||||
|
||||
1. 先阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
2. Base 业务命令仅使用 `lark-cli base +...` 形式的 shortcut 命令。
|
||||
3. 如果输入是 Wiki 链接或 Wiki token,并且用户想读取/操作其中的 Base,先执行 `lark-cli wiki +node-get --node-token <wiki_url_or_token>`;当返回 `data.obj_type=bitable` 时,把 `data.obj_token` 当作 `--base-token`。不要把 URL 里的 `/wiki/{token}` 当成 Base token。(旧的 `--token` flag 仍可用,但已 deprecated,会在 stderr 打印迁移提示。)
|
||||
4. 定位到命令后,先读该命令对应的 reference,再执行命令。
|
||||
5. 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,第一步不是 `base`,而是 `lark-cli drive +import --type bitable`;导入完成后再回到 `lark-cli base +...` 做表内操作。
|
||||
6. 不要在 Base 场景改走 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
7. 如果用户只给 Base 名称、关键词,或说“帮我找一个多维表格”,先通过 `lark-cli drive +search --query <keyword> --doc-types bitable` 搜索 Base / 多维表格资源;拿到 Base URL 后再使用本 skill 的 `base +...` 命令。复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md):标题精确匹配、限定 owner(`--mine` / `--creator-ids`,owner 语义非"最初创建人")/群/文件夹/时间范围、只搜标题/评论、分页/全量搜索。
|
||||
| 用户目标 | 优先命令 | 何时读 reference |
|
||||
|---|---|---|
|
||||
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
|
||||
| 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` |
|
||||
| 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` |
|
||||
| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 |
|
||||
| 创建/更新字段 | `+field-create` / `+field-update` | 必读 `lark-base-field-json.md`;公式读 `formula-field-guide.md`;lookup 读 `lookup-field-guide.md`;命令细节读 `lark-base-field-create.md` / `lark-base-field-update.md` |
|
||||
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 `lark-base-data-analysis-sop.md` |
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读对应 record reference 和 `lark-base-cell-value.md` |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 `lark-base-record-history-list.md`,只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 `lark-base-view-set-filter.md`;其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 `lark-base-data-analysis-sop.md` 和入口 `lark-base-data-query-guide.md`;完整 DSL 再读 `lark-base-data-query.md` |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 `formula-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 `lookup-field-guide.md`,读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 `lark-base-form-detail.md` 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 `lark-base-form-submit.md` |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读对应 form-questions reference |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 `lark-base-form-detail.md`;删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 `lark-base-dashboard.md`;组件 `data_config` 读 `dashboard-block-data-config.md`;读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`;list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 `lark-base-role-guide.md`;角色 create/update 或解读完整配置再读权限 JSON SSOT `role-config.md`;系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
|
||||
## 2. 模块与命令导航
|
||||
## Base 心智模型
|
||||
|
||||
本章按“先选模块,再选命令”的方式组织。先判断用户目标属于哪个大模块,再进入对应子模块,按要求阅读 reference 后执行命令。
|
||||
- Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。
|
||||
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
|
||||
- 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。
|
||||
- 一次性统计、筛选、TopN 优先用 `+data-query` 或临时视图;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
|
||||
- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。
|
||||
- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。
|
||||
- 跨表场景必须读取目标表结构;link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。
|
||||
|
||||
### 2.1 模块地图
|
||||
## 身份与权限降级
|
||||
|
||||
| 大模块 | 处理什么问题 | 包含的小模块 / 能力 |
|
||||
|------|-------------|-------------------|
|
||||
| Base 模块 | 管理 Base 本体,或从链接进入 Base 场景 | `base-create / base-get / base-copy`,Base / Wiki 链接解析 |
|
||||
| 表与数据模块 | 管理 Base 内部结构与日常数据操作 | `table / field / record / view` |
|
||||
| 公式 / Lookup 模块 | 处理派生字段、条件判断、跨表计算、固定查找引用 | `formula / lookup` 字段创建与更新 |
|
||||
| 数据分析模块 | 做一次性筛选、分组、聚合分析 | `data-query` |
|
||||
| Workflow 模块 | 管理自动化流程 | `workflow-list / get / create / update / enable / disable` |
|
||||
| Dashboard 模块 | 管理仪表盘和图表组件 | `dashboard-* / dashboard-block-*` |
|
||||
| 表单模块 | 管理表单和表单题目 | `form-* / form-questions-*` |
|
||||
| 权限与角色模块 | 管理高级权限和自定义角色 | `advperm-* / role-*` |
|
||||
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。
|
||||
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
|
||||
- user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次;bot 仍失败就停止重试并按权限错误处理。
|
||||
- `91403` 或明确不可访问错误不要循环换身份重试。
|
||||
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。
|
||||
|
||||
### 2.2 Base 模块
|
||||
## 查询与统计规则
|
||||
|
||||
用于管理 Base 本体,或从用户给出的链接进入后续 Base 操作。
|
||||
模块索引:[`references/lark-base-workspace.md`](references/lark-base-workspace.md)
|
||||
涉及查询、统计或判断结论时,先阅读 `references/lark-base-data-analysis-sop.md`,并遵守:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `lark-cli drive +search --query <keyword> --doc-types bitable` | 按名称、关键词查找 Base / 多维表格 / bitable | 复杂搜索再读 [`../lark-drive/references/lark-drive-search.md`](../lark-drive/references/lark-drive-search.md) | 先定位资源,再回到 `base +...` 操作表内数据 |
|
||||
| `+base-create` | 创建新的 Base | [`lark-base-base-create.md`](references/lark-base-base-create.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;`--folder-token`、`--time-zone` 都是可选项 |
|
||||
| `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 |
|
||||
| `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;复制成功后应主动返回新 Base 标识信息 |
|
||||
|
||||
### 2.3 表与数据模块
|
||||
|
||||
这是最常用的大模块,包含 `table / field / record / view` 四类子模块。
|
||||
补充示例:[`references/examples.md`](references/examples.md),适合需要串联 table / record / view 完整操作链路时再读。
|
||||
|
||||
#### 2.3.1 Table 子模块
|
||||
|
||||
子模块索引:[`references/lark-base-table.md`](references/lark-base-table.md)
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+table-list / +table-get` | 列出数据表,或获取单个表详情 | [`lark-base-table-list.md`](references/lark-base-table-list.md)、[`lark-base-table-get.md`](references/lark-base-table-get.md) | `+table-list` 只能串行执行;`+table-get` 适合删除/修改前确认目标 |
|
||||
| `+table-create / +table-update / +table-delete` | 创建、更新或删除数据表 | [`lark-base-table-create.md`](references/lark-base-table-create.md)、[`lark-base-table-update.md`](references/lark-base-table-update.md)、[`lark-base-table-delete.md`](references/lark-base-table-delete.md) | 创建适合一次性建表;更新前先确认目标表;删除时用户已明确目标可直接执行并带 `--yes` |
|
||||
|
||||
#### 2.3.2 Field 子模块
|
||||
|
||||
普通字段管理走这里;如果字段类型是 `formula` 或 `lookup`,转到下方“公式 / Lookup 模块”。
|
||||
子模块索引:[`references/lark-base-field.md`](references/lark-base-field.md)
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+field-list / +field-get` | 列出字段结构,或获取单个字段详情 | [`lark-base-field-list.md`](references/lark-base-field-list.md)、[`lark-base-field-get.md`](references/lark-base-field-get.md) | 写记录、写字段、做分析前常先读 `+field-list`;`+field-list` 只能串行执行;`+field-get` 适合删除/更新前确认目标 |
|
||||
| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果涉及类型转换,直接按 `+field-update` 中的字段类型变更规则执行,只在安全白名单内考虑原地转换;如果类型是 `formula / lookup`,先转去读对应 guide;更新或删除时用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+field-search-options` | 查询字段可选项 | [`lark-base-field-search-options.md`](references/lark-base-field-search-options.md) | 适合单选/多选等选项型字段 |
|
||||
|
||||
#### 2.3.3 Record 子模块
|
||||
|
||||
子模块索引:[`references/lark-base-record.md`](references/lark-base-record.md)、[`references/lark-base-history.md`](references/lark-base-history.md)
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 记录读取统一先读 data analysis SOP:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传一个或多个附件 | 看 `lark-cli base +record-upload-attachment --help` | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值;不支持 `--name` |
|
||||
| `+record-download-attachment` | 下载一个或多个 Base 附件到本地 | 看 `lark-cli base +record-download-attachment --help` | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| `+record-remove-attachment` | 删除附件字段里的一个或多个附件 | 看 `lark-cli base +record-remove-attachment --help` | 删除操作;确认目标后带 `--yes` |
|
||||
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
|
||||
|
||||
#### 2.3.4 View 子模块
|
||||
|
||||
子模块索引:[`references/lark-base-view.md`](references/lark-base-view.md)
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+view-list / +view-get` | 列出视图,或获取视图详情 | [`lark-base-view-list.md`](references/lark-base-view-list.md)、[`lark-base-view-get.md`](references/lark-base-view-get.md) | `+view-list` 只能串行执行;`+view-get` 适合查看已有视图配置 |
|
||||
| `+view-create / +view-delete / +view-rename` | 创建、删除或重命名视图 | [`lark-base-view-create.md`](references/lark-base-view-create.md)、[`lark-base-view-delete.md`](references/lark-base-view-delete.md)、[`lark-base-view-rename.md`](references/lark-base-view-rename.md) | 创建前先确认表和视图类型;删除前先确认目标;用户已明确新名字时可直接重命名 |
|
||||
| `+view-get-filter / +view-set-filter` | 读取或配置筛选条件 | [`lark-base-view-get-filter.md`](references/lark-base-view-get-filter.md)、[`lark-base-view-set-filter.md`](references/lark-base-view-set-filter.md)、[`lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md) | 常与 `+record-list` 组合,用于按视图筛选读取 |
|
||||
| `+view-get-sort / +view-set-sort` | 读取或配置排序 | [`lark-base-view-get-sort.md`](references/lark-base-view-get-sort.md)、[`lark-base-view-set-sort.md`](references/lark-base-view-set-sort.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-group / +view-set-group` | 读取或配置分组 | [`lark-base-view-get-group.md`](references/lark-base-view-get-group.md)、[`lark-base-view-set-group.md`](references/lark-base-view-set-group.md) | 字段名必须来自真实结构 |
|
||||
| `+view-get-visible-fields / +view-set-visible-fields` | 读取或配置视图可见字段 | [`lark-base-view-get-visible-fields.md`](references/lark-base-view-get-visible-fields.md)、[`lark-base-view-set-visible-fields.md`](references/lark-base-view-set-visible-fields.md) | 用于控制视图中的字段顺序与可见性;字段名必须来自真实结构 |
|
||||
| `+view-get-card / +view-set-card` | 读取或配置卡片视图 | [`lark-base-view-get-card.md`](references/lark-base-view-get-card.md)、[`lark-base-view-set-card.md`](references/lark-base-view-set-card.md) | 适合卡片展示场景 |
|
||||
| `+view-get-timebar / +view-set-timebar` | 读取或配置时间轴视图 | [`lark-base-view-get-timebar.md`](references/lark-base-view-get-timebar.md)、[`lark-base-view-set-timebar.md`](references/lark-base-view-set-timebar.md) | 适合时间线展示场景 |
|
||||
|
||||
### 2.4 公式 / Lookup 模块
|
||||
|
||||
只要用户诉求涉及派生指标、条件判断、文本处理、日期差、跨表计算、跨表筛选后取值,都要先判断是否进入本模块。
|
||||
|
||||
默认优先考虑 `formula`:适合常规计算、条件判断、文本处理、日期差、跨表聚合,以及需要长期显示在表里的派生结果。
|
||||
只有当用户明确要求 `lookup`,或场景天然符合 `from / select / where / aggregate` 这种固定查找建模时,再使用 `lookup`。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+field-create`(`type=formula`) | 创建公式字段 | [`formula-field-guide.md`](references/formula-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 |
|
||||
| `+field-update`(`type=formula`) | 更新公式字段 | [`formula-field-guide.md`](references/formula-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 先拿当前表结构 |
|
||||
| `+field-create`(`type=lookup`) | 创建 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 |
|
||||
| `+field-update`(`type=lookup`) | 更新 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 跨表时还要拿目标表结构 |
|
||||
|
||||
### 2.5 数据分析模块
|
||||
|
||||
用于一次性分析和临时聚合查询。用户要的是“这次算出来的结果”,而不是把结果沉淀成字段时,优先进入本模块。
|
||||
|
||||
进入本模块前先确认几件事:
|
||||
|
||||
- `+data-query` 只做聚合查询(分组、过滤、排序、聚合计算),不用于列出原始记录或逐条明细。
|
||||
- 调用者必须是目标多维表格的管理员,拥有目标多维表格的 FA(Full Access / 完全访问权限),否则会返回权限错误。
|
||||
- `+data-query` 只支持白名单字段类型;`formula`、`lookup`、附件、系统字段、关联等字段不能用于 `dimensions / measures / filters / sort`。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+data-query` | 做分组统计、SUM / AVG / COUNT / MAX / MIN、条件筛选后的聚合分析 | [`lark-base-data-query.md`](references/lark-base-data-query.md) | 字段名必须精确匹配真实字段名;不要用 `+record-list` / `+record-search` 拉全量再手算;`+data-query` 不返回原始记录;使用前先确认权限和字段类型是否受支持 |
|
||||
|
||||
### 2.6 Workflow 模块
|
||||
|
||||
这是高约束模块。执行任何 workflow 命令前,都必须先读对应命令文档和 schema。
|
||||
模块索引:[`references/lark-base-workflow.md`](references/lark-base-workflow.md)
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+workflow-list / +workflow-get` | 列出 workflow,或获取完整 workflow 结构 | [`lark-base-workflow-list.md`](references/lark-base-workflow-list.md)、[`lark-base-workflow-get.md`](references/lark-base-workflow-get.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | `+workflow-list` 只返回摘要且只能串行执行;需要完整结构时用 `+workflow-get` |
|
||||
| `+workflow-create / +workflow-update` | 创建或更新 workflow | [`lark-base-workflow-create.md`](references/lark-base-workflow-create.md)、[`lark-base-workflow-update.md`](references/lark-base-workflow-update.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 先读 schema;禁止凭自然语言猜 `type`;先确认真实表名和字段名 |
|
||||
| `+workflow-enable / +workflow-disable` | 启用或停用 workflow | [`lark-base-workflow-enable.md`](references/lark-base-workflow-enable.md)、[`lark-base-workflow-disable.md`](references/lark-base-workflow-disable.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 启用或停用前先确认目标 workflow;`workflow_id` 与 `table_id` 需按前缀区分 |
|
||||
|
||||
### 2.7 Dashboard 模块
|
||||
|
||||
当用户提到“仪表盘、dashboard、数据看板、图表、可视化、block、组件、添加组件、创建图表”等关键词时,进入本模块,并先阅读 [`lark-base-dashboard.md`](references/lark-base-dashboard.md)。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+dashboard-list / +dashboard-get` | 列出仪表盘,或获取仪表盘详情 | [`lark-base-dashboard-list.md`](references/lark-base-dashboard-list.md)、[`lark-base-dashboard-get.md`](references/lark-base-dashboard-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 进入仪表盘语义后先读 guide;`+dashboard-list` 只能串行执行 |
|
||||
| `+dashboard-create / +dashboard-update / +dashboard-delete` | 创建、更新或删除仪表盘 | [`lark-base-dashboard-create.md`](references/lark-base-dashboard-create.md)、[`lark-base-dashboard-update.md`](references/lark-base-dashboard-update.md)、[`lark-base-dashboard-delete.md`](references/lark-base-dashboard-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 创建前先明确看板目标和展示场景;更新前先读取当前配置;删除前先确认目标 |
|
||||
| `+dashboard-block-list / +dashboard-block-get` | 列出图表组件,或获取单个 block 详情 | [`lark-base-dashboard-block-list.md`](references/lark-base-dashboard-block-list.md)、[`lark-base-dashboard-block-get.md`](references/lark-base-dashboard-block-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | `+dashboard-block-list` 只能串行执行;查看配置细节时读 block config 文档 |
|
||||
| `+dashboard-block-get-data` | 获取图表组件的计算结果 | [`lark-base-dashboard-block-get-data.md`](references/lark-base-dashboard-block-get-data.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 适合读取图表组件的最终计算结果;此命令不返回 block 元数据,只返回计算结果 |
|
||||
| `+dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` | 创建、更新或删除图表组件 | [`lark-base-dashboard-block-create.md`](references/lark-base-dashboard-block-create.md)、[`lark-base-dashboard-block-update.md`](references/lark-base-dashboard-block-update.md)、[`lark-base-dashboard-block-delete.md`](references/lark-base-dashboard-block-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 涉及 `data_config`、图表类型、filter 时要读 block config 文档;删除前先确认目标 |
|
||||
|
||||
### 2.8 表单模块
|
||||
|
||||
用于管理表单本体和表单题目。
|
||||
模块索引:[`references/lark-base-form.md`](references/lark-base-form.md)、[`references/lark-base-form-questions.md`](references/lark-base-form-questions.md)
|
||||
表单问题相关操作依赖 `form-id`;具体获取方式见 `form-list` 和 `form-create` 的 reference。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+form-list / +form-get` | 列出表单,或获取单个表单 | [`lark-base-form-list.md`](references/lark-base-form-list.md)、[`lark-base-form-get.md`](references/lark-base-form-get.md) | `+form-list` 可用来获取 `form-id`;`+form-get` 适合查看已有表单配置 |
|
||||
| `+form-detail` | 通过表单分享链接获取表单详情(含题目列表、字段类型、校验规则) | [`lark-base-form-detail.md`](references/lark-base-form-detail.md) | 只读;仅需 `--share-token`(从分享链接提取),不需要 base-token/table-id/form-id;返回的 `questions` 可直接用于 `+form-submit` 构造参数 |
|
||||
| `+form-submit` | 通过表单分享链接填写并提交表单(支持普通字段 + 附件上传) | [`lark-base-form-submit.md`](references/lark-base-form-submit.md) | 写入操作;仅支持 share_token 模式;**当 `--json` 包含 attachments 时必须额外提供 `--base-token`**(附件上传到 Base Drive Media 需要);附件通过 `--json.attachments` 传入本地路径,CLI 自动并行上传 |
|
||||
| `+form-create / +form-update / +form-delete` | 创建、更新或删除表单 | [`lark-base-form-create.md`](references/lark-base-form-create.md)、[`lark-base-form-update.md`](references/lark-base-form-update.md)、[`lark-base-form-delete.md`](references/lark-base-form-delete.md) | 创建后可继续进入表单问题相关操作;更新或删除前先确认目标表单 |
|
||||
| `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 |
|
||||
| `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 |
|
||||
|
||||
### 2.9 权限与角色模块
|
||||
|
||||
用于启用高级权限,以及管理 Base 自定义角色。
|
||||
涉及 `+advperm-enable / +advperm-disable / +role-*` 时,操作用户必须为 Base 管理员,否则会返回权限错误。
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+advperm-enable / +advperm-disable` | 启用或停用高级权限 | [`lark-base-advperm-enable.md`](references/lark-base-advperm-enable.md)、[`lark-base-advperm-disable.md`](references/lark-base-advperm-disable.md) | 管理角色前必须先启用;停用是高风险操作,会使已有自定义角色失效 |
|
||||
| `+role-list / +role-get` | 列出角色,或获取角色详情 | [`lark-base-role-list.md`](references/lark-base-role-list.md)、[`lark-base-role-get.md`](references/lark-base-role-get.md)、[`role-config.md`](references/role-config.md) | `+role-list` 只能串行执行;`+role-get` 适合查看完整权限配置 |
|
||||
| `+role-create / +role-update / +role-delete` | 创建、更新或删除角色 | [`lark-base-role-create.md`](references/lark-base-role-create.md)、[`lark-base-role-update.md`](references/lark-base-role-update.md)、[`lark-base-role-delete.md`](references/lark-base-role-delete.md)、[`role-config.md`](references/role-config.md) | `+role-create` 仅支持 `custom_role`;`+role-update` 采用 Delta Merge,`role_name` 和 `role_type` 即使不改也必须传当前值;`+role-delete` 不可逆 |
|
||||
|
||||
## 3. 多维表格通用知识
|
||||
|
||||
飞书多维表格英文名是 `Base`,曾用名 `Bitable`;因此旧文档、返回字段、参数名或错误信息里出现 `bitable` 多属历史兼容,不代表应改用另一套命令体系。
|
||||
|
||||
### 3.1 字段分类与可写性
|
||||
|
||||
| 字段类型 | 含义 | 能否直接作为 `+record-upsert / +record-batch-create / +record-batch-update` 写入目标 | 说明 |
|
||||
|----------|------|-----------------------------------------------------------|------|
|
||||
| 存储字段 | 真实存用户输入的数据 | 可以 | 常见如文本、数字、日期、单选、多选、人员、关联 |
|
||||
| 附件字段 | 存储文件附件 | 不应直接按普通字段写 | 上传附件走 `+record-upload-attachment`;下载附件走 `+record-download-attachment`;删除附件走 `+record-remove-attachment` |
|
||||
| 地理位置字段 | 存储坐标并由平台解析地址 | 可以 | 写入必须使用 `{lng,lat}`;读取、筛选和转文本等场景使用 `full_address` 字符串;只有公式能访问坐标 |
|
||||
| 系统字段 | 平台自动维护 | 不可以 | 常见如创建时间、更新时间、创建人、修改人、自动编号 |
|
||||
| `formula` 字段 | 通过表达式计算 | 不可以 | 只读字段 |
|
||||
| `lookup` 字段 | 通过跨表规则查找引用 | 不可以 | 只读字段 |
|
||||
|
||||
### 3.2 任务选路心智模型
|
||||
|
||||
| 用户诉求 | 优先方案 | 不要误走 |
|
||||
|---------|----------|----------|
|
||||
| 一次性分析 / 临时统计 | `+data-query` | 不要用 `+record-list` / `+record-search` 拉全量后手算 |
|
||||
| 要把结果长期显示在表里 | `formula` 字段 | 不要只给一次性手工分析结果 |
|
||||
| 用户明确要求 lookup,或天然是固定查找配置 | `lookup` 字段 | 不要默认先上 lookup;先判断 formula 是否更合适 |
|
||||
| 读取原始记录明细 / 关键词检索 / 导出 | `+record-search / +record-list / +record-get` | 不要拿 `+data-query` 当取数命令 |
|
||||
| 上传附件到记录 | `+record-upload-attachment` | 不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| 下载记录里的附件文件 | `+record-download-attachment --record-id <record_id> --output <dir>`,可加 `--file-token <file_token>` 只下指定附件 | Base 附件必须用这个命令下载;用其他下载入口可能失败 |
|
||||
| 写入地理位置 | `+record-upsert` / `+record-batch-*` 传 `{lng,lat}` | 不要把纯地址文本当成 CellValue |
|
||||
| 基于视图做筛选读取 | `+view-set-filter` + `+record-list` | 不要跳过视图筛选直接猜条件 |
|
||||
| 本地 Excel / CSV / `.base` 导入为 Base | `lark-cli drive +import --type bitable` | 不要误走 `+base-create`、`+table-create` 或 `+record-upsert` |
|
||||
|
||||
### 3.3 查询执行契约
|
||||
|
||||
涉及查询、统计或判断结论时,先阅读 [`references/lark-base-data-analysis-sop.md`](references/lark-base-data-analysis-sop.md),并遵守以下高优先级规则:
|
||||
|
||||
1. `+record-list` 默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
|
||||
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询服务中执行;不要先拉明细到本地上下文再手工筛选排序。
|
||||
1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实,不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
|
||||
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉明细到本地上下文再手工筛选排序。
|
||||
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
|
||||
4. 多表查询必须先确认关系字段和连接键;link 单元格里的 `record_id` 是关系键,不是用户可读答案。
|
||||
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
|
||||
6. 一次性分析优先用 `+data-query` 或临时视图;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。
|
||||
7. `+data-query` 返回聚合结果,不返回原始记录明细;需要输出实体字段时,用聚合结果中的业务 key 或 record_id 再走 record 路径回查。
|
||||
|
||||
### 3.4 表名、字段名与表达式引用
|
||||
## 写入前置规则
|
||||
|
||||
1. 表名、字段名必须精确匹配真实返回,来源应是 `+table-list / +table-get / +field-list`。
|
||||
2. 不要凭自然语言猜名称,不要自行改写用户口述中的表名、字段名。
|
||||
3. `formula / lookup / data-query / workflow` 中出现的名称同样必须精确匹配;表达式引用、where 条件、DSL 字段名、workflow 配置都遵守同一规则。
|
||||
4. 跨表场景必须额外读取目标表结构,不能只看当前表。
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 `lark-base-field-json.md`;涉及 `formula` / `lookup` 时必须读对应 guide。
|
||||
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
|
||||
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
|
||||
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
|
||||
- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。
|
||||
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。
|
||||
|
||||
### 3.5 Token 与链接
|
||||
## 表单与视图细节
|
||||
|
||||
这是高优先级章节。只要用户输入里出现链接、token,或报错涉及 `baseToken` / `wiki_token` / `obj_token`,都应优先回到这里检查。
|
||||
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 临时视图适合一次性筛选/排序后读取;如果筛选结果对用户后续查看有价值,应保留为持久视图并说明名称和用途。
|
||||
|
||||
| 输入类型 | 正确处理方式 | 说明 |
|
||||
|---------|--------------|------|
|
||||
| 直接 Base 链接 `/base/{token}` | 直接提取 token 作为 `--base-token` | 不要把完整 URL 直接作为 `--base-token` |
|
||||
| Wiki 链接 `/wiki/{token}` | 先用下方 fast path 解析 `data.obj_token` | 不要把 `wiki_token` 直接当 `--base-token`;如果这一步失败,再看 [`lark-wiki-node-get.md`](../lark-wiki/references/lark-wiki-node-get.md) |
|
||||
| URL 中的 `?table={id}` | 先按前缀判断对象类型 | `tbl` 开头表示数据表 `table-id`,可作为 `--table-id`;`blk` 开头表示仪表盘 `dashboard-ID`;`wkf` 开头表示 `workflow-ID`;`ldx` 开头表示内嵌文档,不要一律当成 `--table-id` |
|
||||
| URL 中的 `?view={id}` | 提取为 `--view-id` | 适合直接定位视图 |
|
||||
## Token 与链接
|
||||
|
||||
Wiki Base fast path:
|
||||
| 输入类型 | 含义 / 正确处理方式 |
|
||||
|---|---|
|
||||
| `/base/{token}` | 普通 Base 链接;提取 `/base/` 后的 token 作为 `--base-token` |
|
||||
| `/wiki/{token}` | Wiki 节点链接;先 `wiki +node-get`,当 `data.obj_type=bitable` 时使用 `data.obj_token` 作为 `--base-token` |
|
||||
| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id`;`blk` 开头是 dashboard ID;`wkf` 开头是 workflow ID |
|
||||
| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 |
|
||||
| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token,走 `+form-detail` / `+form-submit --share-token <shareToken>` |
|
||||
| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
|
||||
| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
|
||||
| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>` |
|
||||
| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 |
|
||||
|
||||
```bash
|
||||
BASE_TOKEN="$(lark-cli wiki +node-get --as user --node-token "<wiki_url_or_token>" --jq '.data | select(.obj_type == "bitable") | .obj_token')"
|
||||
```
|
||||
`wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。
|
||||
|
||||
| `lark-cli wiki +node-get` 返回的 `data.obj_type` | 后续路线 | 说明 |
|
||||
|-----------------------------------------------|----------|------|
|
||||
| `bitable` | 优先走 `lark-cli base +...` | 如果 shortcut 不覆盖,再用 `lark-cli base <resource> <method>`;不要改走 `lark-cli api /open-apis/bitable/v1/...` |
|
||||
| `docx` | 转到文档 / Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `sheet` | 转到 Sheets 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `slides` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
| `mindnote` | 转到 Drive 相关 skill | 不继续使用本 skill 的 Base 命令 |
|
||||
## Dashboard / Workflow / Role
|
||||
|
||||
### 3.6 身份选择与权限降级策略
|
||||
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 `dashboard-block-data-config.md`,组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。
|
||||
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 `lark-base-workflow-guide.md` 和 steps JSON SSOT `lark-base-workflow-schema.md`;enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
|
||||
- Role 的复杂点是权限 JSON。角色操作先读入口 `lark-base-role-guide.md`;`+role-create` 只支持自定义角色;`+role-update` 是 delta merge;角色 create/update 或解读完整配置时读权限 JSON SSOT `role-config.md`。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
|
||||
|
||||
多维表格通常属于用户的个人或团队资源。**默认应优先使用 `--as user`(用户身份)执行所有 Base 操作**,始终显式指定身份。
|
||||
## 常见恢复
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份操作其有权访问的 Base。执行前先完成用户授权:
|
||||
| 错误 / 现象 | 恢复动作 |
|
||||
|---|---|
|
||||
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token |
|
||||
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API |
|
||||
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 |
|
||||
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 `lark-base-cell-value.md` 构造 CellValue |
|
||||
| 日期 / 人员 / 超链接字段报格式错误 | 日期用 `YYYY-MM-DD HH:mm:ss`;人员用 `[{ "id": "ou_xxx" }]`;超链接用 URL 或 markdown link 字符串 |
|
||||
| formula / lookup 创建失败 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
|
||||
| `ignored_fields` / `READONLY` | 移除只读字段,只写存储字段 |
|
||||
| `1254104` | 批量超过 200,分批调用 |
|
||||
| `1254291` | 并发写冲突,串行写入并在批次间短暂等待 |
|
||||
| `91403` | 无权限访问该 Base,按 `lark-shared` 权限流程处理,不要盲目重试 |
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain base
|
||||
```
|
||||
## 保留 Reference
|
||||
|
||||
- **`--as bot`(降级)**:仅当 user 身份权限不足、且 bot 身份确实拥有目标 Base 的访问权限时,才降级使用。bot 看不到用户私有资源,行为以应用身份执行。
|
||||
|
||||
**执行规则**:
|
||||
|
||||
1. 所有操作默认先用 `--as user`。
|
||||
2. 若 user 身份返回权限错误,先判断是否为**不可重试错误码**(如 `91403`)。若是,**立即停止**,不做任何重试或降级,直接按 `lark-shared` 权限不足处理流程引导用户解决。
|
||||
3. 非不可重试错误码时,检查错误响应中是否包含 `permission_violations` / `hint` 等提权引导信息:
|
||||
- **有提权引导**:按 `lark-shared` 权限不足处理流程,先引导用户完成 user 身份提权(`auth login --scope`);确认提权成功后,以 `--as user` 重试。
|
||||
- **无提权引导**(如资源级无访问权限、非 scope 不足):切换到 `--as bot` 重试**一次**。
|
||||
4. 若 bot 身份仍然返回权限错误,**立即停止重试**,根据错误响应按 `lark-shared` 流程引导用户解决(引导去开发者后台开通 scope 或确认资源访问权限)。
|
||||
5. 只有在用户明确要求"用应用身份 / bot 身份操作",才跳过 user 直接使用 `--as bot`。
|
||||
|
||||
## 4. 执行规则
|
||||
|
||||
### 4.1 标准执行顺序
|
||||
|
||||
1. 先判断任务属于哪个模块,选对命令族。
|
||||
2. 如果用户给了链接,先解析 token,不要把 wiki token、完整 URL 或其他对象 ID 误当成 `base_token`。
|
||||
3. 如果是查询类任务,先判断问题范围,阅读 data analysis SOP,再决定使用 `record / view / data-query`。
|
||||
4. 先拿结构,再写命令,避免猜表名、字段名、表达式引用。
|
||||
5. 定位到命令后,先读对应 reference,再执行命令。
|
||||
6. 执行命令,并按返回结果判断下一步。
|
||||
7. 回复时返回关键结果和后续可继续操作的信息,方便 agent 链式执行下一步。
|
||||
|
||||
### 4.2 不可违反规则
|
||||
|
||||
1. 先拿结构,再写命令;至少先拿当前表结构,跨表时还要拿目标表结构。
|
||||
2. 不要猜表名、字段名、表达式引用,一律以真实返回为准。
|
||||
3. 只使用原子命令;不要回退到旧的聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
4. 写记录前先读字段结构;先 `+field-list`,再按 [`lark-base-cell-value.md`](references/lark-base-cell-value.md) 构造 CellValue。
|
||||
5. 写字段前先看字段属性规范;先读 `lark-base-shortcut-field-properties.md`,再构造 `+field-create / +field-update` 的 JSON。
|
||||
6. 只写可写字段;系统字段、附件字段、`formula`、`lookup` 默认不作为普通记录写入目标。
|
||||
7. 聚合分析与取数分流;统计走 `+data-query`,关键词检索走 `+record-search`,明细走 `+record-list / +record-get`。
|
||||
8. 筛选查询按视图能力执行;先用 `+view-set-filter` 配置筛选,再结合 `+record-list` 读取。
|
||||
9. 全局查询不得基于默认分页、小 `--limit` 或未证明全量的本地 `jq` 结果下结论。
|
||||
10. Base 场景不要改走裸 API,不要切去 `lark-cli api /open-apis/bitable/v1/...`。
|
||||
11. 统一使用 `--base-token`。
|
||||
12. workflow 场景先读 schema,不要凭自然语言猜 `type`。
|
||||
13. dashboard 场景先读 guide;提到图表、看板、block 就先进入 dashboard 模块。
|
||||
14. formula / lookup 场景先读 guide;没读 guide 前不要直接创建或更新。
|
||||
|
||||
### 4.3 并发、分页与批量限制
|
||||
|
||||
- `+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list / +dashboard-list / +dashboard-block-list / +workflow-list` 禁止并发调用,只能串行执行。
|
||||
- `+record-list` 分页时,`--limit` 最大 `200`;先拉首批并检查 `has_more`,只有用户明确需要更多数据时再继续翻页。
|
||||
- 批量写入时,单批不超过 `200` 条。
|
||||
- 连续写入同一表时,必须串行写入,批次间延迟 `0.5–1` 秒。
|
||||
|
||||
### 4.4 确认与回复规则
|
||||
|
||||
- 视图重命名时,用户已明确“把哪个视图改成什么名字”时,`+view-rename` 直接执行即可。
|
||||
- 更新字段或删除记录 / 字段 / 表时,如果用户已经明确目标,`+field-update / +record-delete / +field-delete / +table-delete` 可直接执行,并带 `--yes`。
|
||||
- 删除目标仍有歧义时,先用 `+record-get / +field-get / +table-get` 或相应 list 命令确认。
|
||||
- `+base-create / +base-copy` 成功后,回复中必须主动返回新 Base 的标识信息;若结果带可访问链接,也应一并返回。
|
||||
- 若 Base 由 bot 身份创建或复制,shortcut 会自动尝试为当前 CLI 用户补授 `full_access`,并在输出中返回 `permission_grant`;agent 不需要再手动编排单独授权。owner 转移必须单独确认,禁止擅自执行。
|
||||
|
||||
## 5. 常见错误与恢复
|
||||
|
||||
| 错误 / 现象 | 含义 | 恢复动作 |
|
||||
|-------------|------|----------|
|
||||
| `1254064` | 日期格式错误 | 传 `YYYY-MM-DD HH:mm:ss` 字符串,不要写相对时间 |
|
||||
| `1254068` | 超链接格式错误 | `"https://example.com"` 或 `"[文本](https://example.com)"` |
|
||||
| `1254066` | 人员字段错误 | `[{ "id": "ou_xxx" }]` |
|
||||
| `1254045` | 字段名不存在 | 检查字段名(含空格、大小写) |
|
||||
| `1254015` | 字段值类型不匹配 | 先 `+field-list`,再按类型构造 |
|
||||
| `param baseToken is invalid` / `base_token invalid` | 把 wiki token、workspace token 或其他 token 当成了 `base_token` | 如果输入来自 `/wiki/...`,先用 `lark-cli wiki +node-get --node-token <wiki_url_or_token>` 取真实 `data.obj_token`;当 `data.obj_type=bitable` 时,用 `data.obj_token` 作为 `--base-token` 重试,不要改走 `bitable/v1` |
|
||||
| `not found` 且用户给的是 wiki 链接 | 常见于把 wiki token 当成 base token | 优先回退检查 wiki 解析,而不是改走 `bitable/v1` |
|
||||
| formula / lookup 创建失败 | 指南未读或结构不合法 | 先读 `formula-field-guide.md` / `lookup-field-guide.md`,再按 guide 重建请求 |
|
||||
| `ignored_fields` / `READONLY` | 只读字段被当成可写字段,常见于系统字段、formula、lookup | 移除只读字段,只写存储字段;计算结果交给 formula / lookup / 系统字段自动产出 |
|
||||
| `1254104` | 批量超 200 条 | 分批调用 |
|
||||
| `1254291` | 并发写冲突 | 串行写入 + 批次间延迟 |
|
||||
| `91403` | 无权限访问该 Base | **不要重试**。按 `lark-shared` 权限不足处理流程引导用户解决权限问题 |
|
||||
- `lark-base-data-analysis-sop.md`:查询/统计/全局结论的选路 SOP
|
||||
- `lark-base-data-query-guide.md` / `lark-base-data-query.md`:聚合查询入口 fewshot 与 DSL SSOT
|
||||
- `lark-base-cell-value.md`:记录 CellValue 构造
|
||||
- `lark-base-field-json.md`:字段 JSON 构造
|
||||
- `formula-field-guide.md` / `lookup-field-guide.md`:公式与 lookup 字段
|
||||
- `lark-base-field-create.md` / `lark-base-field-update.md`:字段创建/更新命令级补充
|
||||
- `lark-base-record-upsert.md` / `lark-base-record-batch-create.md` / `lark-base-record-batch-update.md` / `lark-base-record-history-list.md`:记录写入 JSON 与历史返回解释
|
||||
- `lark-base-view-set-filter.md`:视图筛选 JSON
|
||||
- `lark-base-form-detail.md` / `lark-base-form-submit.md` / `lark-base-form-questions-create.md` / `lark-base-form-questions-update.md`:表单详情、提交和复杂 JSON
|
||||
- `lark-base-dashboard.md` / `dashboard-block-data-config.md` / `lark-base-dashboard-block-get-data.md`:仪表盘、组件配置与图表结果协议
|
||||
- `lark-base-workflow-guide.md` / `lark-base-workflow-schema.md`:workflow 入口与 steps JSON SSOT
|
||||
- `lark-base-role-guide.md` / `role-config.md`:角色入口与权限 JSON SSOT
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user