mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
44 Commits
codex/base
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b449614be9 | ||
|
|
8e2f517827 | ||
|
|
f6353368d7 | ||
|
|
c4270566ba | ||
|
|
75e8063bf2 | ||
|
|
ae8b1a4cf3 | ||
|
|
01ae4cc521 | ||
|
|
99ac5d6906 | ||
|
|
fd97e65b55 | ||
|
|
4e2c6a2096 | ||
|
|
f619e34d38 | ||
|
|
3e1623c631 | ||
|
|
b2551fd70b | ||
|
|
50b1f54bfa | ||
|
|
f8ed143bde | ||
|
|
92c5b0f26d | ||
|
|
47f4fda1d3 | ||
|
|
7866801115 | ||
|
|
3624499bcb | ||
|
|
13d4350557 | ||
|
|
daeab10755 | ||
|
|
6402cb6a3a | ||
|
|
8491775659 | ||
|
|
f5e0d14ca9 | ||
|
|
240e523dbf | ||
|
|
9223754af1 | ||
|
|
3788b6f601 | ||
|
|
0e95848dd7 | ||
|
|
e41e36e1d9 | ||
|
|
a5032bbb55 | ||
|
|
d8f6154e2f | ||
|
|
d219d61be0 | ||
|
|
f79908483d | ||
|
|
52b5910fb1 | ||
|
|
0b59556207 | ||
|
|
91743bba99 | ||
|
|
ca7135f582 | ||
|
|
7b58ba1b1d | ||
|
|
765b097d44 | ||
|
|
4a5e2c519a | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 | ||
|
|
5fb70d326a |
@@ -310,6 +310,10 @@ lark-cli config risk-control default
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
|
||||
|
||||
@@ -311,6 +311,10 @@ lark-cli config risk-control default
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
@@ -179,8 +179,8 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
// Registration is platform traffic, so it must use the provider-aware
|
||||
// transport as well as the shared proxy configuration.
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
|
||||
@@ -157,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity checks are platform traffic and must exercise the same
|
||||
// provider-aware route as real platform requests.
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
|
||||
@@ -12,18 +12,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
@@ -272,55 +263,3 @@ func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(&Provider{})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seen := make(chan *http.Request, 2)
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seen <- req.Clone(req.Context())
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
resp, err := client.Do(withSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = client.Do(withoutSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
proxied := <-seen
|
||||
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
|
||||
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
|
||||
}
|
||||
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
|
||||
t.Fatalf("sentinel request proxy target = %q", got)
|
||||
}
|
||||
|
||||
passthrough := <-seen
|
||||
if got := passthrough.URL.String(); got != "https://external.example/public" {
|
||||
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,27 +15,6 @@ type Provider interface {
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// RequestClass describes the trust boundary of an outbound HTTP request.
|
||||
// Platform requests target endpoints owned by the CLI's endpoint resolver;
|
||||
// external requests target user-provided, pre-signed, CDN, registry, or other
|
||||
// non-platform URLs. Redirect targets are classified again from each hop's
|
||||
// logical URL; rewriting a host in an interceptor does not add that host to
|
||||
// the platform endpoint catalog.
|
||||
type RequestClass string
|
||||
|
||||
const (
|
||||
RequestClassPlatform RequestClass = "platform"
|
||||
RequestClassExternal RequestClass = "external"
|
||||
)
|
||||
|
||||
// ScopedProvider optionally limits a Provider to selected request classes.
|
||||
// Providers that do not implement this interface retain the original
|
||||
// behavior and apply to every request class.
|
||||
type ScopedProvider interface {
|
||||
Provider
|
||||
SupportsRequestClass(RequestClass) bool
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
// and checks for security policy errors.
|
||||
type SecurityPolicyTransport struct {
|
||||
@@ -33,16 +31,6 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base().RoundTrip(req)
|
||||
|
||||
@@ -212,9 +212,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
@@ -518,29 +518,6 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
|
||||
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, policyErr
|
||||
})},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/files/file_token/download",
|
||||
}, core.AsBot)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !errors.Is(err, policyErr) {
|
||||
t.Fatal("DoStream() did not preserve the typed transport error")
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). The hint is deliberately NOT a pre-built retry
|
||||
// command: argv cannot faithfully reproduce the original invocation (pipeline
|
||||
// producers, stdin bytes, redirections, inline env and the executable's real
|
||||
// path are all gone), POSIX quoting does not survive PowerShell/cmd.exe, and
|
||||
// echoing argv values can copy credentials or free-form payloads (--sql,
|
||||
// --json) into the error envelope and every log that captures it. Per the
|
||||
// lark-shared approval protocol, the caller that obtained the user's consent
|
||||
// appends --yes to its own saved argv array and re-executes.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
// The hint is the plain add-yes contract and nothing more: no pre-built
|
||||
// retry command may ride behind it (argv cannot faithfully reproduce the
|
||||
// invocation and may carry sensitive payloads — see RequireConfirmation).
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
t.Errorf("Hint = %q, want exactly 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the typed protocol stays
|
||||
// action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
@@ -33,7 +31,7 @@ type InvocationContext struct {
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
@@ -50,18 +48,6 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ExternalHTTPClient returns a clone of the existing Factory client whose
|
||||
// requests are explicitly classified as external. The underlying client,
|
||||
// redirect policy, timeout, proxy configuration, and legacy transport provider
|
||||
// behavior are preserved.
|
||||
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
|
||||
client, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
|
||||
@@ -5,18 +5,16 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -50,19 +48,6 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
core.SetCurrentWorkspace(ws)
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
|
||||
return resolveSDKHostSignalSource(workspaceConfig)
|
||||
})
|
||||
// Install after workspace selection so the dependency bootstrap bridge uses
|
||||
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
|
||||
// consumers, so this keeps their request routing identical to cmd.Execute.
|
||||
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return buildSDKPlatformTransportWithBase(
|
||||
base,
|
||||
bootstrapHostSignalSource(),
|
||||
)
|
||||
})
|
||||
|
||||
// Inject workspace-aware dir into keychain's log system.
|
||||
// This breaks the core↔keychain import cycle by using a function variable.
|
||||
@@ -70,6 +55,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
@@ -101,45 +87,15 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
|
||||
// HEAD requests. This allows API download redirects while preventing OAuth or
|
||||
// other credential-bearing request bodies from being replayed to another
|
||||
// origin. HTTPS requests can never be downgraded to HTTP.
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
|
||||
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
|
||||
// redirects; other headers like X-Cli-* pass through.
|
||||
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
original := via[0]
|
||||
previous := via[len(via)-1]
|
||||
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"redirect from HTTPS to %s is not allowed",
|
||||
req.URL.Scheme,
|
||||
)
|
||||
}
|
||||
if !sameRedirectOrigin(previous.URL, req.URL) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect for HTTP method %s is not allowed",
|
||||
req.Method,
|
||||
)
|
||||
}
|
||||
if req.Body != nil || req.GetBody != nil {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect with a request body is not allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
// net/http copies initial headers onto every redirect request. Continue
|
||||
// stripping credentials for every hop outside the initial origin, even when
|
||||
// two consecutive redirect targets share an origin.
|
||||
if !sameRedirectOrigin(original.URL, req.URL) {
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("X-Lark-MCP-UAT")
|
||||
req.Header.Del("X-Lark-MCP-TAT")
|
||||
@@ -147,29 +103,6 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRedirectOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectivePort(left) == effectivePort(right)
|
||||
}
|
||||
|
||||
func effectivePort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
|
||||
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
|
||||
// needed because the real function is guarded by an internal sync.Once, so
|
||||
@@ -185,12 +118,15 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
shared := transport.Shared()
|
||||
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
|
||||
platform := buildDirectHTTPTransport(outbound, true)
|
||||
external := buildDirectHTTPTransport(outbound, false)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||
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.NewHTTPPolicyRouter(platform, external),
|
||||
Transport: rt,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -198,15 +134,6 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
@@ -222,8 +149,14 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
var sdkBase http.RoundTripper = transport.Shared()
|
||||
// The innermost SDK boundary always strips reserved host-signal headers;
|
||||
// a nil source makes it strip-only when workspace policy disables signal
|
||||
// collection.
|
||||
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
|
||||
sdkTransport := wrapSDKTransport(sdkBase)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(hostSignalSource),
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -232,41 +165,12 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
|
||||
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
|
||||
}
|
||||
|
||||
func buildSDKPlatformTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return buildSDKHTTPTransport(outbound, true)
|
||||
}
|
||||
|
||||
func buildSDKTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
// Risk control is the innermost trusted boundary for both request classes.
|
||||
// It therefore observes the final URL and strips extension-supplied reserved
|
||||
// headers immediately before the network transport.
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return transport.NewHTTPPolicyRouter(
|
||||
buildSDKHTTPTransport(outbound, true),
|
||||
buildSDKHTTPTransport(outbound, false),
|
||||
)
|
||||
}
|
||||
|
||||
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &UserAgentTransport{Base: builtIn}
|
||||
builtIn = &BuildHeaderTransport{Base: builtIn}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -40,7 +33,7 @@ func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -51,7 +44,7 @@ func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -61,283 +54,3 @@ func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
|
||||
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
|
||||
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if external == base {
|
||||
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
|
||||
}
|
||||
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
|
||||
t.Fatal("ExternalHTTPClient did not preserve client policy")
|
||||
}
|
||||
if base.Transport != nil {
|
||||
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
|
||||
}
|
||||
}
|
||||
|
||||
type platformOnlyStubProvider struct {
|
||||
*stubTransportProvider
|
||||
}
|
||||
|
||||
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassPlatform
|
||||
}
|
||||
|
||||
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
externalClient, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, client := range []*http.Client{platformClient, externalClient} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
platformHeaders := <-received
|
||||
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
|
||||
}
|
||||
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
|
||||
}
|
||||
|
||||
externalHeaders := <-received
|
||||
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
|
||||
t.Fatalf("external request leaked extension header %q", got)
|
||||
}
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := externalHeaders.Get(header); got != want {
|
||||
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
exttransport.Register(nil)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
|
||||
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
if _, err := platform.Get(server.URL); err == nil {
|
||||
t.Fatal("platform request error = nil, want security policy classification")
|
||||
} else {
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) {
|
||||
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
|
||||
}
|
||||
}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := external.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("external request parsed platform error protocol: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
redirect.Header.Set(header, "secret")
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
if got := redirect.Header.Get(header); got != "" {
|
||||
t.Fatalf("redirect retained %s=%q", header, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "request body") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
|
||||
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
|
||||
if err == nil || err.Error() != "too many redirects" {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
|
||||
}
|
||||
if got := redirect.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed error", err)
|
||||
}
|
||||
if problem.Category != category || problem.Subtype != subtype {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %s/%s, want %s/%s",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
category,
|
||||
subtype,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ var proxyWarnGateCases = []struct {
|
||||
{"non-terminal stderr stays silent", false, 0},
|
||||
}
|
||||
|
||||
// TestCachedHTTPClientFunc_ProxyWarnGate verifies the HTTP client init path
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHTTPClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -77,6 +77,11 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
// All paths go through the caller's fileIO provider and its relative-to-cwd
|
||||
// policy — no absolute-path side door: a trust root defined by the process
|
||||
// environment (TMPDIR) is not a security boundary, and reading outside the
|
||||
// provider would break sidecar/custom-FileIO ownership. Out-of-tree content
|
||||
// reaches flags via stdin ("-").
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
|
||||
URL: "/test",
|
||||
Body: "ok",
|
||||
})
|
||||
// Use the stub via Factory HttpClient.
|
||||
// Use the stub via Factory HttpClient
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("HttpClient() error: %v", err)
|
||||
|
||||
@@ -4,19 +4,14 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
// and network errors. MaxRetries defaults to 0 (no retries).
|
||||
type RetryTransport struct {
|
||||
@@ -32,16 +27,6 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
if t.Delay > 0 {
|
||||
return t.Delay
|
||||
@@ -78,19 +63,6 @@ type UserAgentTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderUserAgent, UserAgentValue())
|
||||
@@ -101,25 +73,14 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
// X-Cli-Build header before every request. It remains in the SDK transport
|
||||
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
|
||||
// X-Cli-Build header before every request. Used in the SDK transport chain,
|
||||
// where SecurityHeaderTransport is not installed, to prevent extensions from
|
||||
// tampering with the build classification. The direct HTTP chain is already
|
||||
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
|
||||
type BuildHeaderTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderBuild, DetectBuildKind())
|
||||
@@ -142,16 +103,6 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
@@ -169,3 +120,67 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
return t.base().RoundTrip(req)
|
||||
}
|
||||
|
||||
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
|
||||
// The built-in chain always executes unless the extension is an
|
||||
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
|
||||
// error; it cannot otherwise be skipped or overridden.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent
|
||||
// extensions from tampering with cancellation, deadlines, or built-in values.
|
||||
// Cloning the request isolates header/URL/etc. mutations from the caller's
|
||||
// request object; req.Body is intentionally shared — extensions that consume
|
||||
// it are responsible for rewinding (see Interceptor doc).
|
||||
type extensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
|
||||
}
|
||||
|
||||
// RoundTrip invokes the interceptor pre hook, restores the original context,
|
||||
// executes the built-in chain (unless aborted), then calls the post hook if
|
||||
// non-nil. When the extension implements AbortableInterceptor and returns a
|
||||
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
|
||||
// *exttransport.AbortError is returned; the post hook is still invoked with
|
||||
// (nil, reason) so extensions can unwind resources.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortEr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortEr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortEr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortEr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// wrapWithExtension wraps transport with the registered extension middleware.
|
||||
// If no extension is registered, returns transport unchanged.
|
||||
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return transport
|
||||
}
|
||||
tr := p.ResolveInterceptor(context.Background())
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -91,107 +91,94 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildSDKTransport policy behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
for _, class := range []exttransport.RequestClass{
|
||||
exttransport.RequestClassPlatform,
|
||||
exttransport.RequestClassExternal,
|
||||
} {
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
class,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
headers := <-received
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := headers.Get(header); got != want {
|
||||
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if !interceptor.preCalled || !interceptor.postCalled {
|
||||
t.Fatal("SDK platform request did not execute extension pre/post hooks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
|
||||
t.Fatalf(
|
||||
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
|
||||
buildSDKTransport(nil),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
base := &http.Transport{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
source, ok := client.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
rebuilt, concrete, ok := source.CloneHTTPTransport()
|
||||
if !ok || rebuilt == nil || concrete == nil {
|
||||
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
if concrete == base {
|
||||
t.Fatal("SDK policy graph reused the original HTTP transport")
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +238,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -279,16 +266,14 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildTamperingInterceptor tries to delete and spoof security headers via
|
||||
// PreRoundTrip. The SDK built-in chain must restore the real values before the
|
||||
// request leaves the process.
|
||||
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
|
||||
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
|
||||
// value before the request leaves the process.
|
||||
type buildTamperingInterceptor struct{}
|
||||
|
||||
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Del(HeaderBuild)
|
||||
req.Header.Set(HeaderBuild, "ext-tampered-build")
|
||||
req.Header.Del(HeaderSource)
|
||||
req.Header.Set(HeaderSource, "ext-tampered-source")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -300,74 +285,7 @@ func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http
|
||||
return nil
|
||||
}
|
||||
|
||||
type bootstrapPolicyTamperingInterceptor struct{}
|
||||
|
||||
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(HeaderSource, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldTransport := http.DefaultClient.Transport
|
||||
oldCheckRedirect := http.DefaultClient.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient.Transport = oldTransport
|
||||
http.DefaultClient.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{
|
||||
interceptor: bootstrapPolicyTamperingInterceptor{},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
var received http.Header
|
||||
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
http.DefaultClient.Transport = network
|
||||
http.DefaultClient.CheckRedirect = nil
|
||||
_ = NewDefault(nil, InvocationContext{})
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://open.feishu.cn/callback/ws/endpoint",
|
||||
strings.NewReader(`{"app_secret":"secret"}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := received.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
|
||||
}
|
||||
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
|
||||
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
|
||||
}
|
||||
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
|
||||
}
|
||||
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
@@ -383,11 +301,7 @@ func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Do(req)
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -398,13 +312,14 @@ func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// SDK chain restores both the build classification and the full security
|
||||
// header set after an extension runs.
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
// closes the gap where the SDK chain had no equivalent of
|
||||
// SecurityHeaderTransport (see design doc §3.3.3).
|
||||
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
var receivedBuild, receivedSource string
|
||||
var receivedBuild string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
receivedSource = r.Header.Get(HeaderSource)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -412,13 +327,12 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK built-in chain inside buildSDKTransport.
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
transport := wrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -435,9 +349,6 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
if receivedSource != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
|
||||
@@ -527,7 +438,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
@@ -589,7 +500,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -630,7 +541,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
_, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -649,7 +560,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -66,39 +63,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
var platformEndpointHosts = func() map[string]struct{} {
|
||||
hosts := make(map[string]struct{})
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := ResolveEndpoints(brand)
|
||||
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err == nil && parsed.Hostname() != "" {
|
||||
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}()
|
||||
|
||||
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
|
||||
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
|
||||
// suffix match: lookalike external domains must never enter the platform
|
||||
// transport extension.
|
||||
func IsPlatformEndpointHost(hostname string) bool {
|
||||
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
|
||||
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
|
||||
// excluded even when their hostname matches.
|
||||
func IsPlatformEndpointURL(candidate *url.URL) bool {
|
||||
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
|
||||
return false
|
||||
}
|
||||
if port := candidate.Port(); port != "" && port != "443" {
|
||||
return false
|
||||
}
|
||||
return IsPlatformEndpointHost(candidate.Hostname())
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
@@ -95,85 +91,3 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"open.feishu.cn",
|
||||
"accounts.feishu.cn",
|
||||
"mcp.feishu.cn",
|
||||
"applink.feishu.cn",
|
||||
"open.larksuite.com",
|
||||
"accounts.larksuite.com",
|
||||
"mcp.larksuite.com",
|
||||
"applink.larksuite.com",
|
||||
} {
|
||||
if !IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{
|
||||
"example.com",
|
||||
"open.feishu.cn.example.com",
|
||||
"notopen.feishu.cn",
|
||||
"",
|
||||
} {
|
||||
if IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
|
||||
for i := 0; i < endpoints.NumField(); i++ {
|
||||
rawURL := endpoints.Field(i).String()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
|
||||
}
|
||||
if !IsPlatformEndpointHost(parsed.Hostname()) {
|
||||
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
|
||||
if IsPlatformEndpointURL(nil) {
|
||||
t.Error("IsPlatformEndpointURL(nil) = true, want false")
|
||||
}
|
||||
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
|
||||
if !IsPlatformEndpointURL(uppercaseScheme) {
|
||||
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:8443/path",
|
||||
"https://open.feishu.cn.example.com/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:443/path",
|
||||
"https://OPEN.FEISHU.CN/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
var baseCodeMeta = map[int]CodeMeta{
|
||||
// Copy Table domain errors (technical design chapter 18.2).
|
||||
800020304: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
|
||||
800010102: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition},
|
||||
800080105: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800040819: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict},
|
||||
800070003: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
800100112: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown},
|
||||
800100113: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown},
|
||||
800040114: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
|
||||
800070115: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
800010109: {Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument},
|
||||
800030110: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound},
|
||||
800070111: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
|
||||
// Shared RPC errors used by Copy Table (technical design chapter 18.3).
|
||||
800040802: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800040803: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800020812: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
|
||||
800040832: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800040817: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
|
||||
800080821: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
|
||||
800070831: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
|
||||
}
|
||||
|
||||
func init() {
|
||||
mergeCodeMeta(baseCodeMeta, "base")
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMetaBaseTableCopyCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
retryable bool
|
||||
}{
|
||||
// Copy Table domain errors documented in chapter 18.2.
|
||||
{code: 800020304, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied},
|
||||
{code: 800010102, category: errs.CategoryValidation, subtype: errs.SubtypeFailedPrecondition},
|
||||
{code: 800080105, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800040819, category: errs.CategoryAPI, subtype: errs.SubtypeConflict},
|
||||
{code: 800070003, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
{code: 800100112, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown},
|
||||
{code: 800100113, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown},
|
||||
{code: 800040114, category: errs.CategoryAPI, subtype: errs.SubtypeConflict, retryable: true},
|
||||
{code: 800070115, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
{code: 800010109, category: errs.CategoryValidation, subtype: errs.SubtypeInvalidArgument},
|
||||
{code: 800030110, category: errs.CategoryAPI, subtype: errs.SubtypeNotFound},
|
||||
{code: 800070111, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
|
||||
// Shared RPC errors used by Copy Table, documented in chapter 18.3.
|
||||
{code: 800040802, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800040803, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800020812, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied},
|
||||
{code: 800040832, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800040817, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
|
||||
{code: 800080821, category: errs.CategoryPolicy, subtype: errs.SubtypeAccessDenied},
|
||||
{code: 800070831, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprint(test.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(test.code)
|
||||
if !ok || meta.Category != test.category || meta.Subtype != test.subtype || meta.Retryable != test.retryable {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %#v, %v", test.code, meta, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ type Stub struct {
|
||||
RawBody []byte // raw bytes (takes precedence over Body when non-nil)
|
||||
ContentType string // override Content-Type header (default: application/json)
|
||||
Headers http.Header // optional full response headers (takes precedence over ContentType)
|
||||
Error error // optional transport error returned after OnMatch
|
||||
matched bool
|
||||
|
||||
// BodyFilter (optional): match only when the captured request body satisfies
|
||||
@@ -94,9 +93,6 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if matched.OnMatch != nil {
|
||||
matched.OnMatch(req)
|
||||
}
|
||||
if matched.Error != nil {
|
||||
return nil, matched.Error
|
||||
}
|
||||
resp, err := stubResponse(matched)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package httpmock
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -113,21 +112,3 @@ func TestRegistry_CustomStatus(t *testing.T) {
|
||||
t.Errorf("want status 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_TransportError(t *testing.T) {
|
||||
wantErr := errors.New("connection reset")
|
||||
reg := &Registry{}
|
||||
reg.Register(&Stub{
|
||||
Method: "POST",
|
||||
URL: "/transport-error",
|
||||
Error: wantErr,
|
||||
})
|
||||
|
||||
client := NewClient(reg)
|
||||
req, _ := http.NewRequest("POST", "https://example.com/transport-error", nil)
|
||||
_, err := client.Do(req)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want transport error %v", err, wantErr)
|
||||
}
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
@@ -180,8 +180,8 @@ func saveCachedMerged(data []byte, cm CacheMeta) error {
|
||||
// localVersion is sent as data_version query param for server-side version comparison.
|
||||
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
|
||||
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
||||
// Remote metadata is platform traffic and must honor both the shared proxy
|
||||
// configuration and the registered platform transport extension.
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
@@ -42,28 +40,6 @@ func NewTransport(next http.RoundTripper, source Source) *Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// BaseRoundTripper exposes the network transport so policy routers can clone
|
||||
// and rebuild the complete decorator graph without dropping risk control.
|
||||
func (t *Transport) BaseRoundTripper() http.RoundTripper {
|
||||
if t == nil || t.next == nil {
|
||||
return internaltransport.Fallback()
|
||||
}
|
||||
return t.next
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper returns an equivalent risk-control boundary over base.
|
||||
func (t *Transport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
if t == nil {
|
||||
return NewTransport(base, nil)
|
||||
}
|
||||
cloned := *t
|
||||
if base == nil {
|
||||
base = internaltransport.Fallback()
|
||||
}
|
||||
cloned.next = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
||||
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), the LARK_CLI_NO_PROXY
|
||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
||||
//
|
||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestMatcher func(*http.Request) bool
|
||||
type transportPolicyBuilder func(http.RoundTripper) http.RoundTripper
|
||||
|
||||
type sdkBootstrapRedirectContextKey struct{}
|
||||
|
||||
var (
|
||||
// larkws pins this client during package initialization.
|
||||
sdkBootstrapHTTPClient = http.DefaultClient
|
||||
installDefaultClientMu sync.Mutex
|
||||
)
|
||||
|
||||
// sdkBootstrapTransport applies the platform HTTP policy only to dependency
|
||||
// bootstrap requests selected by match. Unmatched DefaultClient traffic is
|
||||
// delegated directly to the previous transport.
|
||||
type sdkBootstrapTransport struct {
|
||||
base http.RoundTripper
|
||||
match requestMatcher
|
||||
buildPlatformPolicy transportPolicyBuilder
|
||||
|
||||
policyMu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if !t.isBootstrapRequest(req) {
|
||||
return t.fallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
base := t.base
|
||||
if base == nil {
|
||||
// Resolve Shared lazily so bridge installation never initializes
|
||||
// workspace-scoped proxy state ahead of workspace selection.
|
||||
base = Shared()
|
||||
}
|
||||
buildPlatformPolicy := t.platformPolicyBuilder()
|
||||
if buildPlatformPolicy == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy is not configured",
|
||||
)
|
||||
}
|
||||
base = buildPlatformPolicy(base)
|
||||
if base == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy returned a nil transport",
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve extensions per hop so redirects retain platform policy.
|
||||
extended := WrapWithExtensionForClass(base, exttransport.RequestClassPlatform)
|
||||
guarded := &sameOriginRedirectTransport{base: extended}
|
||||
return guarded.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) platformPolicyBuilder() transportPolicyBuilder {
|
||||
t.policyMu.RLock()
|
||||
defer t.policyMu.RUnlock()
|
||||
return t.buildPlatformPolicy
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) setPlatformPolicyBuilder(build transportPolicyBuilder) {
|
||||
t.policyMu.Lock()
|
||||
t.buildPlatformPolicy = build
|
||||
t.policyMu.Unlock()
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) isBootstrapRequest(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
if _, redirected := req.Context().Value(sdkBootstrapRedirectContextKey{}).(struct{}); redirected {
|
||||
return true
|
||||
}
|
||||
return t.match != nil && t.match(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) fallbackTransport() http.RoundTripper {
|
||||
if t.base != nil {
|
||||
return t.base
|
||||
}
|
||||
// Preserve net/http's dynamic nil-Transport fallback.
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// sameOriginRedirectTransport rejects redirects before net/http can replay a
|
||||
// bootstrap request to a different logical origin.
|
||||
type sameOriginRedirectTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || !isFollowedRedirect(resp.StatusCode) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resp, nil
|
||||
}
|
||||
target, parseErr := req.URL.Parse(location)
|
||||
if parseErr != nil {
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"platform request returned an invalid redirect location: %v",
|
||||
parseErr,
|
||||
).WithCause(parseErr)
|
||||
}
|
||||
if sameOrigin(req.URL, target) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"platform bootstrap blocked cross-origin redirect from %q to %q",
|
||||
originName(req.URL),
|
||||
originName(target),
|
||||
)
|
||||
}
|
||||
|
||||
// sdkBootstrapRedirectPolicy preserves the prior hook and marks each redirect hop.
|
||||
func sdkBootstrapRedirectPolicy(
|
||||
match requestMatcher,
|
||||
previous func(*http.Request, []*http.Request) error,
|
||||
) func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if previous != nil {
|
||||
if err := previous(req, via); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(via) >= 10 {
|
||||
// Retain net/http's default redirect limit.
|
||||
return errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"stopped after 10 redirects",
|
||||
)
|
||||
}
|
||||
|
||||
if req == nil || len(via) == 0 || match == nil || !match(via[0]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), sdkBootstrapRedirectContextKey{}, struct{}{})
|
||||
*req = *req.WithContext(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func originName(candidate *url.URL) string {
|
||||
if candidate == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(candidate.Scheme) + "://" + candidate.Host
|
||||
}
|
||||
|
||||
func sameOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
originPort(left) == originPort(right)
|
||||
}
|
||||
|
||||
func originPort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isFollowedRedirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently,
|
||||
http.StatusFound,
|
||||
http.StatusSeeOther,
|
||||
http.StatusTemporaryRedirect,
|
||||
http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InstallSDKTransportBridge wraps larkws's captured HTTP bootstrap client. All
|
||||
// requests through that client hit the bridge, but only matched bootstrap
|
||||
// traffic uses platform policy. The SDK owns the subsequent WebSocket dial,
|
||||
// which does not use this net/http transport.
|
||||
func InstallSDKTransportBridge(buildPlatformPolicy func(http.RoundTripper) http.RoundTripper) {
|
||||
installDefaultClientMu.Lock()
|
||||
defer installDefaultClientMu.Unlock()
|
||||
installSDKTransportBridge(
|
||||
sdkBootstrapHTTPClient,
|
||||
isSDKWebSocketBootstrapRequest,
|
||||
buildPlatformPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
func isSDKWebSocketBootstrapRequest(req *http.Request) bool {
|
||||
return req != nil &&
|
||||
req.Method == http.MethodPost &&
|
||||
core.IsPlatformEndpointURL(req.URL) &&
|
||||
req.URL.Path == larkws.GenEndpointUri
|
||||
}
|
||||
|
||||
func installSDKTransportBridge(
|
||||
client *http.Client,
|
||||
match requestMatcher,
|
||||
buildPlatformPolicy transportPolicyBuilder,
|
||||
) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if existing, ok := client.Transport.(*sdkBootstrapTransport); ok {
|
||||
existing.setPlatformPolicyBuilder(buildPlatformPolicy)
|
||||
return
|
||||
}
|
||||
base := client.Transport
|
||||
previousRedirect := client.CheckRedirect
|
||||
client.Transport = &sdkBootstrapTransport{
|
||||
base: base,
|
||||
match: match,
|
||||
buildPlatformPolicy: buildPlatformPolicy,
|
||||
}
|
||||
client.CheckRedirect = sdkBootstrapRedirectPolicy(match, previousRedirect)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil)
|
||||
|
||||
type resolvedExtension struct {
|
||||
provider exttransport.Provider
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func resolveExtension() *resolvedExtension {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
interceptor := p.ResolveInterceptor(context.Background())
|
||||
if interceptor == nil {
|
||||
return nil
|
||||
}
|
||||
return &resolvedExtension{provider: p, interceptor: interceptor}
|
||||
}
|
||||
|
||||
func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
if e == nil {
|
||||
return base
|
||||
}
|
||||
if enforceScope {
|
||||
if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()}
|
||||
}
|
||||
|
||||
// ExtensionMiddleware wraps the built-in transport chain with extension
|
||||
// pre/post hooks. The built-in chain always executes unless an
|
||||
// exttransport.AbortableInterceptor rejects the request.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent an
|
||||
// extension from replacing cancellation, deadlines, or built-in values. The
|
||||
// request is cloned so URL and header mutations do not alter the caller's
|
||||
// request object. The body remains shared; interceptors that consume it must
|
||||
// restore it before returning.
|
||||
type ExtensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string
|
||||
}
|
||||
|
||||
// BaseRoundTripper returns the wrapped built-in transport chain.
|
||||
func (m *ExtensionMiddleware) BaseRoundTripper() http.RoundTripper {
|
||||
if m.Base == nil {
|
||||
return Shared()
|
||||
}
|
||||
return m.Base
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper clones the middleware over base.
|
||||
func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *m
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip invokes the extension pre hook, the wrapped transport, and then
|
||||
// the optional post hook. Abortable interceptors can stop the request before
|
||||
// the wrapped transport is called.
|
||||
func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortErr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortErr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortErr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortErr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortErr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx)
|
||||
resp, err := m.BaseRoundTripper().RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// WrapWithExtension wraps base with the currently registered transport
|
||||
// extension. With no registered provider or no resolved interceptor, base is
|
||||
// returned unchanged.
|
||||
func WrapWithExtension(base http.RoundTripper) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, "", false)
|
||||
}
|
||||
|
||||
// WrapWithExtensionForClass wraps base only when the registered provider
|
||||
// supports class. Providers without the optional ScopedProvider interface keep
|
||||
// their historical all-request behavior.
|
||||
func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, class, true)
|
||||
}
|
||||
@@ -1,924 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type testProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
resolveCalls *int
|
||||
}
|
||||
|
||||
func (p testProvider) Name() string { return "test-provider" }
|
||||
|
||||
func (p testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if p.resolveCalls != nil {
|
||||
*p.resolveCalls++
|
||||
}
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type scopedTestProvider struct {
|
||||
testProvider
|
||||
supported exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == p.supported
|
||||
}
|
||||
|
||||
type testHeaderInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Test-Platform", "routed")
|
||||
return nil
|
||||
}
|
||||
|
||||
type abortingTestInterceptor struct {
|
||||
reason error
|
||||
post func(*http.Response, error)
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
|
||||
panic("PreRoundTrip called for abortable interceptor")
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTripE(*http.Request) (func(*http.Response, error), error) {
|
||||
return i.post, i.reason
|
||||
}
|
||||
|
||||
func TestLegacyProviderKeepsAllRequestBehavior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
for _, client := range []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("external request header = %q, want routed for legacy provider", got)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want exactly 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedProviderOnlyRunsForSupportedRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clients := []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
}
|
||||
for _, client := range clients {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "" {
|
||||
t.Fatalf("external request received scoped provider header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) {
|
||||
resolveCalls := 0
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{
|
||||
interceptor: &testHeaderInterceptor{},
|
||||
resolveCalls: &resolveCalls,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
_ = NewHTTPPolicyRouter(base, base)
|
||||
|
||||
if resolveCalls != 1 {
|
||||
t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
var relayBody string
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/relay"), nil
|
||||
case "/relay":
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relayBody = string(body)
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusPermanentRedirect,
|
||||
"https://external.example/target",
|
||||
), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
const secret = "app_secret=secret"
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader(secret),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want cross-origin redirect rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if relayBody != secret {
|
||||
t.Fatalf("same-origin relay body = %q, want %q", relayBody, secret)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("cross-origin target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardClassifiesInvalidLocation(t *testing.T) {
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return redirectResponse(req, http.StatusFound, "%"), nil
|
||||
})
|
||||
client := &http.Client{Transport: &sameOriginRedirectTransport{base: base}}
|
||||
resp, err := client.Get("https://platform.example/bootstrap")
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid redirect location") {
|
||||
t.Fatalf("Do() error = %v, want invalid redirect rejection", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/invalid_response", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectPolicyInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectPolicyInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Extension-Hop", strconv.Itoa(i.calls))
|
||||
req.Header.Set("X-Reserved", "extension")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeRetainsPoliciesAcrossSameOriginRedirect(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectPolicyInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
var finalHeaders http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/next"), nil
|
||||
case "/next":
|
||||
finalHeaders = req.Header.Clone()
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
builtInCalls := 0
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(
|
||||
client,
|
||||
func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
},
|
||||
func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
builtInCalls++
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Builtin-Hop", strconv.Itoa(builtInCalls))
|
||||
req.Header.Set("X-Reserved", "trusted")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if finalHeaders == nil {
|
||||
t.Fatal("same-origin redirect target was not called")
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
if builtInCalls != 2 {
|
||||
t.Fatalf("built-in policy calls = %d, want 2", builtInCalls)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Extension-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Extension-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Builtin-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Builtin-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Reserved"); got != "trusted" {
|
||||
t.Fatalf("final X-Reserved = %q, want trusted built-in value", got)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectRewriteInterceptor struct {
|
||||
target *url.URL
|
||||
postLocation string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectRewriteInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.URL.Scheme = i.target.Scheme
|
||||
req.URL.Host = i.target.Host
|
||||
if i.postLocation == "" {
|
||||
return nil
|
||||
}
|
||||
return func(resp *http.Response, err error) {
|
||||
if err == nil && resp != nil && isFollowedRedirect(resp.StatusCode) {
|
||||
resp.Header.Set("Location", i.postLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing.T) {
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sidecarCalls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host != sidecarURL.Host {
|
||||
t.Fatalf("network host = %q, want extension target %q", req.URL.Host, sidecarURL.Host)
|
||||
}
|
||||
sidecarCalls++
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
case "/next":
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectRewriteInterceptor{target: sidecarURL}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if sidecarCalls != 2 {
|
||||
t.Fatalf("sidecar calls = %d, want 2", sidecarCalls)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: &redirectRewriteInterceptor{
|
||||
target: sidecarURL,
|
||||
postLocation: "https://external.example/target",
|
||||
}},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("secret"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want post-hook Location rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("post-hook redirect target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOriginNormalizesDefaultPort(t *testing.T) {
|
||||
left, err := url.Parse("https://platform.example/bootstrap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse("https://platform.example:443/next")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameOrigin(left, right) {
|
||||
t.Fatal("sameOrigin() = false for equivalent default HTTPS ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultClientBridgeCoversWebSocketSDKBootstrap(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seenHeader := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
seenHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"code":400,"msg":"stop after bootstrap"}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
installSDKTransportBridge(sdkBootstrapHTTPClient, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Host == strings.TrimPrefix(server.URL, "http://")
|
||||
}, identityTransportPolicy)
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithDomain(server.URL),
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := <-seenHeader; got != "routed" {
|
||||
t.Fatalf("WebSocket bootstrap header = %q, want routed", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1 bootstrap call", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUsesPinnedClientAfterGlobalReplacement(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
oldDefaultClient := http.DefaultClient
|
||||
t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
|
||||
|
||||
var pinnedCalls atomic.Int32
|
||||
pinnedHeader := make(chan string, 1)
|
||||
sdkBootstrapHTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
pinnedCalls.Add(1)
|
||||
pinnedHeader <- req.Header.Get("X-Pinned-Bridge")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":400,"msg":"stop"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
sdkBootstrapHTTPClient.CheckRedirect = nil
|
||||
|
||||
var replacementCalls atomic.Int32
|
||||
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
replacementCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Pinned-Bridge", "routed")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
})
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := pinnedCalls.Load(); got != 1 {
|
||||
t.Fatalf("SDK-pinned client calls = %d, want 1", got)
|
||||
}
|
||||
if got := <-pinnedHeader; got != "routed" {
|
||||
t.Fatalf("SDK-pinned bridge header = %q, want routed", got)
|
||||
}
|
||||
if got := replacementCalls.Load(); got != 0 {
|
||||
t.Fatalf("replacement DefaultClient calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKWebSocketBootstrapMatcherIsNarrow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "platform bootstrap",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "other platform path",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
{
|
||||
name: "wrong bootstrap method",
|
||||
method: http.MethodGet,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
},
|
||||
{
|
||||
name: "external lookalike",
|
||||
method: http.MethodPost,
|
||||
url: "https://external.example/callback/ws/endpoint",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tt.method, tt.url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := isSDKWebSocketBootstrapRequest(req); got != tt.want {
|
||||
t.Fatalf("isSDKWebSocketBootstrapRequest() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeLeavesOtherPlatformPathsUntouched(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
baseCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, isSDKWebSocketBootstrapRequest, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if baseCalls != 1 {
|
||||
t.Fatalf("base calls = %d, want 1", baseCalls)
|
||||
}
|
||||
if interceptor.calls != 0 {
|
||||
t.Fatalf("extension calls = %d, want 0 for unmatched DefaultClient traffic", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeNilBasePreservesDefaultTransportForUnmatchedRequest(t *testing.T) {
|
||||
oldDefaultTransport := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = oldDefaultTransport })
|
||||
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var firstCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
firstCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := &http.Client{}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return false }, nil)
|
||||
|
||||
var currentCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
currentCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/unmatched", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := firstCalls.Load(); got != 0 {
|
||||
t.Fatalf("install-time DefaultTransport calls = %d, want 0", got)
|
||||
}
|
||||
if got := currentCalls.Load(); got != 1 {
|
||||
t.Fatalf("request-time DefaultTransport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUpdatesPlatformPolicy(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return noContentResponse(req), nil
|
||||
})}
|
||||
var firstCalls, secondCalls int
|
||||
build := func(calls *int) transportPolicyBuilder {
|
||||
return func(base http.RoundTripper) http.RoundTripper {
|
||||
*calls++
|
||||
return base
|
||||
}
|
||||
}
|
||||
match := func(*http.Request) bool { return true }
|
||||
installSDKTransportBridge(client, match, build(&firstCalls))
|
||||
installSDKTransportBridge(client, match, build(&secondCalls))
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if firstCalls != 0 || secondCalls != 1 {
|
||||
t.Fatalf("policy calls = (%d, %d), want (0, 1)", firstCalls, secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedWithoutPlatformPolicy(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "policy is not configured") {
|
||||
t.Fatalf("Do() error = %v, want missing policy rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedForNilPlatformTransport(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, func(http.RoundTripper) http.RoundTripper {
|
||||
return nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "nil transport") {
|
||||
t.Fatalf("Do() error = %v, want nil policy transport rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectPolicyRetainsDefaultLimit(t *testing.T) {
|
||||
policy := sdkBootstrapRedirectPolicy(nil, nil)
|
||||
via := make([]*http.Request, 10)
|
||||
err := policy(&http.Request{}, via)
|
||||
if err == nil {
|
||||
t.Fatal("redirect policy error = nil after 10 redirects")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryNetwork ||
|
||||
problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("redirect problem = %#v, %v; want network/transport", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareUsesFallbackWhenBaseIsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
previous := http.DefaultTransport
|
||||
var calls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previous })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := (&ExtensionMiddleware{Ext: &testHeaderInterceptor{}}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("fallback transport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareAbortsBeforeBase(t *testing.T) {
|
||||
reason := errors.New("blocked")
|
||||
baseCalled := false
|
||||
postCalled := false
|
||||
interceptor := &abortingTestInterceptor{
|
||||
reason: reason,
|
||||
post: func(resp *http.Response, err error) {
|
||||
postCalled = true
|
||||
if resp != nil || err != reason {
|
||||
t.Errorf("post arguments = (%v, %v), want (nil, reason)", resp, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
middleware := &ExtensionMiddleware{
|
||||
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
baseCalled = true
|
||||
return nil, nil
|
||||
}),
|
||||
Ext: interceptor,
|
||||
ExtName: "test-provider",
|
||||
}
|
||||
|
||||
resp, err := middleware.RoundTrip(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %v, want nil", resp)
|
||||
}
|
||||
var abortErr *exttransport.AbortError
|
||||
if !errors.As(err, &abortErr) {
|
||||
t.Fatalf("error = %T, want *transport.AbortError", err)
|
||||
}
|
||||
if abortErr.Extension != "test-provider" || abortErr.Reason != reason {
|
||||
t.Fatalf("abort error = %#v, want provider and reason", abortErr)
|
||||
}
|
||||
if baseCalled {
|
||||
t.Fatal("base transport was called")
|
||||
}
|
||||
if !postCalled {
|
||||
t.Fatal("post hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveHTTPClientState(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
oldTransport := client.Transport
|
||||
oldCheckRedirect := client.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
client.Transport = oldTransport
|
||||
client.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
}
|
||||
|
||||
func identityTransportPolicy(base http.RoundTripper) http.RoundTripper {
|
||||
return base
|
||||
}
|
||||
|
||||
func redirectResponse(req *http.Request, status int, location string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Location": []string{location}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse(req *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestClassContextKey struct{}
|
||||
type forcedRequestClassContextKey struct{}
|
||||
|
||||
// HTTPPolicyRouter selects an HTTP transport policy from request intent and
|
||||
// the endpoint catalog. Explicit request intent takes precedence; otherwise
|
||||
// known platform endpoints use the platform policy and all other URLs use the
|
||||
// external policy.
|
||||
type HTTPPolicyRouter struct {
|
||||
platform http.RoundTripper
|
||||
external http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTripperDecorator describes a transport layer that can be rebuilt over
|
||||
// a cloned base transport. Connection-policy helpers use this contract to
|
||||
// preserve retry, response, and extension layers while safely customizing the
|
||||
// innermost *http.Transport.
|
||||
type RoundTripperDecorator interface {
|
||||
BaseRoundTripper() http.RoundTripper
|
||||
WithBaseRoundTripper(http.RoundTripper) http.RoundTripper
|
||||
}
|
||||
|
||||
// NewHTTPPolicyRouter constructs a router over two policy chains. A nil chain
|
||||
// falls back to the shared proxy-aware transport. The currently registered
|
||||
// extension provider is resolved once and applied according to its optional
|
||||
// ScopedProvider contract.
|
||||
func NewHTTPPolicyRouter(platform, external http.RoundTripper) *HTTPPolicyRouter {
|
||||
if platform == nil {
|
||||
platform = Shared()
|
||||
}
|
||||
if external == nil {
|
||||
external = Shared()
|
||||
}
|
||||
|
||||
extension := resolveExtension()
|
||||
return &HTTPPolicyRouter{
|
||||
platform: extension.wrap(platform, exttransport.RequestClassPlatform, true),
|
||||
external: extension.wrap(external, exttransport.RequestClassExternal, true),
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip dispatches the request to its selected policy chain.
|
||||
func (r *HTTPPolicyRouter) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"HTTP policy router received a nil request",
|
||||
)
|
||||
}
|
||||
class, err := classifyRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if class == exttransport.RequestClassPlatform {
|
||||
return r.platform.RoundTrip(req)
|
||||
}
|
||||
return r.external.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (r *HTTPPolicyRouter) transportForClass(class exttransport.RequestClass) (http.RoundTripper, bool) {
|
||||
switch class {
|
||||
case exttransport.RequestClassPlatform:
|
||||
return r.platform, true
|
||||
case exttransport.RequestClassExternal:
|
||||
return r.external, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func classifyRequest(req *http.Request) (exttransport.RequestClass, error) {
|
||||
if explicit, ok := req.Context().Value(requestClassContextKey{}).(exttransport.RequestClass); ok {
|
||||
switch explicit {
|
||||
case exttransport.RequestClassPlatform, exttransport.RequestClassExternal:
|
||||
return explicit, nil
|
||||
default:
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"unsupported HTTP request class %q",
|
||||
explicit,
|
||||
)
|
||||
}
|
||||
}
|
||||
if core.IsPlatformEndpointURL(req.URL) {
|
||||
return exttransport.RequestClassPlatform, nil
|
||||
}
|
||||
return exttransport.RequestClassExternal, nil
|
||||
}
|
||||
|
||||
// WithRequestClass returns a shallow copy of req with explicit routing intent.
|
||||
func WithRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withForcedRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
if _, forced := req.Context().Value(forcedRequestClassContextKey{}).(struct{}); forced {
|
||||
return req
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
ctx = context.WithValue(ctx, forcedRequestClassContextKey{}, struct{}{})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
type requestClassTransport struct {
|
||||
base http.RoundTripper
|
||||
class exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (t *requestClassTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.base.RoundTrip(withForcedRequestClass(req, t.class))
|
||||
}
|
||||
|
||||
// CloneHTTPTransport exposes a structural cloning capability without requiring
|
||||
// higher-level safety helpers to import this package. The explicit request
|
||||
// class selects the policy branch that must be rebuilt.
|
||||
func (t *requestClassTransport) CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool) {
|
||||
return CloneHTTPTransportForRequestClass(t.base, t.class)
|
||||
}
|
||||
|
||||
// TransformHTTPTransport clones the selected policy branch and replaces its
|
||||
// concrete transport in place. Keeping the replacement at the graph leaf is
|
||||
// important for policies that must observe requests after outer decorators
|
||||
// have run, such as proxy selection.
|
||||
func (t *requestClassTransport) TransformHTTPTransport(transform func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool) {
|
||||
return transformHTTPTransportForRequestClass(t.base, t.class, transform, 0)
|
||||
}
|
||||
|
||||
// ClientForRequestClass clones client and forces all of its requests through a
|
||||
// specific policy class. The original client is never mutated.
|
||||
func ClientForRequestClass(client *http.Client, class exttransport.RequestClass) *http.Client {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
cloned := *client
|
||||
base := client.Transport
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
cloned.Transport = &requestClassTransport{base: base, class: class}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// CloneHTTPTransportForRequestClass selects one policy branch, clones its
|
||||
// innermost *http.Transport, and rebuilds every composable decorator around
|
||||
// the clone. Callers can customize concrete before using rebuilt. The original
|
||||
// transport graph is never mutated.
|
||||
func CloneHTTPTransportForRequestClass(base http.RoundTripper, class exttransport.RequestClass) (rebuilt http.RoundTripper, concrete *http.Transport, ok bool) {
|
||||
rebuilt, ok = transformHTTPTransportForRequestClass(base, class, func(cloned *http.Transport) (http.RoundTripper, bool) {
|
||||
concrete = cloned
|
||||
return cloned, true
|
||||
}, 0)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return rebuilt, concrete, true
|
||||
}
|
||||
|
||||
func transformHTTPTransportForRequestClass(
|
||||
base http.RoundTripper,
|
||||
class exttransport.RequestClass,
|
||||
transform func(*http.Transport) (http.RoundTripper, bool),
|
||||
depth int,
|
||||
) (http.RoundTripper, bool) {
|
||||
if depth > 32 {
|
||||
return nil, false
|
||||
}
|
||||
if base == nil || transform == nil {
|
||||
if transform == nil {
|
||||
return nil, false
|
||||
}
|
||||
base = Shared()
|
||||
}
|
||||
|
||||
switch current := base.(type) {
|
||||
case *http.Transport:
|
||||
cloned := cloneHTTPTransport(current)
|
||||
rebuilt, valid := transform(cloned)
|
||||
return rebuilt, valid && rebuilt != nil
|
||||
case *requestClassTransport:
|
||||
return transformHTTPTransportForRequestClass(current.base, class, transform, depth+1)
|
||||
case *HTTPPolicyRouter:
|
||||
selected, valid := current.transportForClass(class)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
return transformHTTPTransportForRequestClass(selected, class, transform, depth+1)
|
||||
case RoundTripperDecorator:
|
||||
inner := current.BaseRoundTripper()
|
||||
if inner == nil || inner == base {
|
||||
return nil, false
|
||||
}
|
||||
rebuiltInner, valid := transformHTTPTransportForRequestClass(inner, class, transform, depth+1)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
rebuilt := current.WithBaseRoundTripper(rebuiltInner)
|
||||
return rebuilt, rebuilt != nil
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
// Clone leaves an auto-configured h2 handler on source.
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type cloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &cloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
type headerCloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Decorator", "applied")
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &headerCloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterClassifiesFromEndpointCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/open-apis/test",
|
||||
"https://example.com/file",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if platformCalls != 1 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 1 each", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterExplicitClassOverridesCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassExternal)
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 0 and 1", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOutermostIntentWins(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
resp, err := external.Get("https://open.feishu.cn/open-apis/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want outer external intent to win", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsInvalidExplicitClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClass("invalid"))
|
||||
if _, err := router.RoundTrip(req); err == nil || !strings.Contains(err.Error(), "unsupported HTTP request class") {
|
||||
t.Fatalf("RoundTrip() error = %v, want unsupported request class", err)
|
||||
} else if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsNilRequest(t *testing.T) {
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
_, err := router.RoundTrip(nil)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterReclassifiesRedirectTargets(t *testing.T) {
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
receivedHeader := make(chan string, 1)
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
receivedHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(external.Close)
|
||||
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusFound,
|
||||
Header: http.Header{"Location": []string{external.URL}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
http.DefaultTransport,
|
||||
)
|
||||
client := &http.Client{Transport: router}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := <-receivedHeader; got != "" {
|
||||
t.Fatalf("redirect target received platform-scoped header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want only the initial platform request", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassRebuildsDecorators(t *testing.T) {
|
||||
wantErr := errors.New("preserved proxy policy")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
decorated := &cloneTestDecorator{base: base}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
|
||||
rebuilt, concrete, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if concrete == base {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() reused the original *http.Transport")
|
||||
}
|
||||
if _, ok := rebuilt.(*cloneTestDecorator); !ok {
|
||||
t.Fatalf("rebuilt transport type = %T, want *cloneTestDecorator", rebuilt)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassPreservesAutomaticHTTP2(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
router := NewHTTPPolicyRouter(&http.Transport{}, source)
|
||||
|
||||
_, cloned, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if !cloned.ForceAttemptHTTP2 {
|
||||
t.Fatal("ForceAttemptHTTP2 = false, want true")
|
||||
}
|
||||
if cloned.TLSNextProto != nil {
|
||||
t.Fatal("TLSNextProto is non-nil, want automatic HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassKeepsOutermostIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := external.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no clone capability", external.Transport)
|
||||
}
|
||||
rebuilt, _, ok := source.CloneHTTPTransport()
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransport() ok = false")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want outer external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOverridesCallerIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassPlatform)
|
||||
|
||||
if _, err := client.Do(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("Do() error = %v, want forced external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformHTTPTransportReplacesLeafInsideDecorators(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
decorated := &headerCloneTestDecorator{base: &http.Transport{}}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := client.Transport.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no transform capability", client.Transport)
|
||||
}
|
||||
rebuilt, ok := source.TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool) {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if got := req.Header.Get("X-Decorator"); got != "applied" {
|
||||
t.Fatalf("leaf received X-Decorator = %q, want applied", got)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}), true
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("TransformHTTPTransport() ok = false")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := rebuilt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
@@ -57,29 +55,21 @@ func Fallback() *http.Transport {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns a policy-routed client over the shared proxy-aware
|
||||
// transport. Known platform endpoints use the platform request class; all
|
||||
// other URLs use the external request class. Existing unscoped transport
|
||||
// providers continue to apply to both classes.
|
||||
// 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 {
|
||||
base := Shared()
|
||||
return &http.Client{
|
||||
Transport: NewHTTPPolicyRouter(base, base),
|
||||
Transport: Shared(),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExternalHTTPClient returns a client for user-provided, pre-signed, CDN,
|
||||
// package-registry, and other non-platform URLs. It forces the external policy
|
||||
// while preserving the shared proxy configuration and the historical behavior
|
||||
// of unscoped transport providers. A zero timeout means no client-level timeout.
|
||||
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
return ClientForRequestClass(NewHTTPClient(timeout), exttransport.RequestClassExternal)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -88,24 +88,23 @@ func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientConstructors verifies both the policy-routed client and its
|
||||
// forced-external view retain explicit transports and configured timeouts.
|
||||
func TestHTTPClientConstructors(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)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
for name, client := range map[string]*http.Client{
|
||||
"routed": NewHTTPClient(7 * time.Second),
|
||||
"external": NewExternalHTTPClient(7 * time.Second),
|
||||
} {
|
||||
if client.Transport == nil {
|
||||
t.Fatalf("%s client transport is nil", name)
|
||||
}
|
||||
if client.Timeout != 7*time.Second {
|
||||
t.Errorf("%s client timeout = %v, want 7s", name, client.Timeout)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,32 +153,4 @@ func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
||||
}
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
client *http.Client
|
||||
url string
|
||||
}{
|
||||
"platform": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
"external": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
"forced external": {
|
||||
client: NewExternalHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp, err := test.client.Get(test.url)
|
||||
if err == nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
t.Fatalf("policy-routed client succeeded with malformed proxy config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,10 @@ func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return transport.NewExternalHTTPClient(fetchTimeout)
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,8 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
@@ -22,30 +19,6 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
type updateExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p updateExternalProvider) Name() string { return "update-external-test" }
|
||||
|
||||
func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type updateExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSkipEnv unsets all env vars that shouldSkip checks,
|
||||
// preventing the host environment (e.g. CI=true) from polluting test results.
|
||||
func clearSkipEnv(t *testing.T) {
|
||||
@@ -269,46 +242,6 @@ func TestRefreshCache(t *testing.T) {
|
||||
RefreshCache("1.0.0")
|
||||
}
|
||||
|
||||
func TestHTTPClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
previousClient := DefaultClient
|
||||
DefaultClient = nil
|
||||
t.Cleanup(func() { DefaultClient = previousClient })
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &updateExternalInterceptor{}
|
||||
exttransport.Register(updateExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingAtomicAccess(t *testing.T) {
|
||||
// Initially nil
|
||||
if got := GetPending(); got != nil {
|
||||
|
||||
@@ -5,15 +5,11 @@ package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,9 +34,6 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 0 { // RFC 1122 "this network"
|
||||
return true
|
||||
}
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
@@ -59,9 +52,6 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
|
||||
return true
|
||||
}
|
||||
if v4[0] >= 240 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() {
|
||||
@@ -86,42 +76,32 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are supported")
|
||||
}
|
||||
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
|
||||
return err
|
||||
}
|
||||
|
||||
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
|
||||
|
||||
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
|
||||
host := strings.TrimSpace(strings.ToLower(rawHost))
|
||||
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("URL host is required")
|
||||
return fmt.Errorf("URL host is required")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
return []net.IP{ip}, nil
|
||||
return nil
|
||||
}
|
||||
if lookupIP == nil {
|
||||
lookupIP = net.DefaultResolver.LookupIP
|
||||
}
|
||||
ips, err := lookupIP(ctx, "ip", host)
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
|
||||
@@ -135,10 +115,7 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
cloned.Transport = &downloadSchemeTransport{
|
||||
base: cloneDownloadTransport(base.Transport),
|
||||
allowHTTP: opts.AllowHTTP,
|
||||
}
|
||||
cloned.Transport = cloneDownloadTransport(base.Transport)
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= opts.MaxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
@@ -161,310 +138,18 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
return &cloned
|
||||
}
|
||||
|
||||
type downloadSchemeTransport struct {
|
||||
base http.RoundTripper
|
||||
allowHTTP bool
|
||||
}
|
||||
|
||||
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"download transport received a nil request",
|
||||
)
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(req.URL.Scheme, "https"):
|
||||
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
|
||||
default:
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"only https URLs are supported",
|
||||
)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
type selectedDownloadProxyKey struct{}
|
||||
|
||||
type proxyAwareDownloadTransport struct {
|
||||
selectProxy func(*http.Request) (*url.URL, error)
|
||||
direct http.RoundTripper
|
||||
proxied *http.Transport
|
||||
lookupIP downloadLookupIPFunc
|
||||
|
||||
mu sync.Mutex
|
||||
proxiedByTLSServer map[string]*http.Transport
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download transport received a nil request")
|
||||
}
|
||||
proxyURL, err := t.selectProxy(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proxyURL == nil {
|
||||
return t.direct.RoundTrip(req)
|
||||
}
|
||||
|
||||
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
|
||||
if err != nil {
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
|
||||
// HTTP proxies cannot pin the target IP separately from the Host header.
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"plain HTTP hostname downloads through a proxy are not allowed",
|
||||
).WithHint("use HTTPS or a literal public IP")
|
||||
}
|
||||
|
||||
selected := *proxyURL
|
||||
proxied := t.proxied
|
||||
if strings.EqualFold(req.URL.Scheme, "https") {
|
||||
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for index, targetIP := range targetIPs {
|
||||
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
|
||||
if pinErr != nil {
|
||||
return nil, pinErr
|
||||
}
|
||||
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
|
||||
proxiedReq = proxiedReq.WithContext(ctx)
|
||||
|
||||
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
|
||||
if roundTripErr == nil {
|
||||
if resp != nil {
|
||||
// Hide the internal pinned URL from redirect handling.
|
||||
resp.Request = req
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = roundTripErr
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if req.Context().Err() != nil {
|
||||
break
|
||||
}
|
||||
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
|
||||
break
|
||||
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else {
|
||||
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
|
||||
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
|
||||
closer.CloseIdleConnections()
|
||||
}
|
||||
t.proxied.CloseIdleConnections()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, transport := range t.proxiedByTLSServer {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
|
||||
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
|
||||
serverName = configured.ServerName
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
|
||||
return transport
|
||||
}
|
||||
|
||||
transport := t.proxied.Clone()
|
||||
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
|
||||
targetTLSConfig.ServerName = serverName
|
||||
transport.TLSClientConfig = targetTLSConfig
|
||||
configureHTTPSProxyTLSDialer(transport, t.proxied)
|
||||
if t.proxiedByTLSServer == nil {
|
||||
t.proxiedByTLSServer = make(map[string]*http.Transport)
|
||||
}
|
||||
t.proxiedByTLSServer[serverName] = transport
|
||||
return transport
|
||||
}
|
||||
|
||||
type blockedDownloadTransport struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, t.err
|
||||
}
|
||||
|
||||
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if source, ok := base.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
}); ok {
|
||||
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
if source, ok := base.(*http.Transport); ok && source != nil {
|
||||
rebuilt, transformed := newDownloadTransportLeaf(source)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
return &blockedDownloadTransport{err: errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"cannot safely clone download transport %T",
|
||||
base,
|
||||
)}
|
||||
}
|
||||
|
||||
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
|
||||
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
|
||||
}
|
||||
|
||||
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
|
||||
if source == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
selectProxy := source.Proxy
|
||||
direct := cloneDownloadHTTPTransport(source)
|
||||
direct.Proxy = nil
|
||||
configureDirectDownloadTransport(direct)
|
||||
if selectProxy == nil {
|
||||
return direct, true
|
||||
}
|
||||
|
||||
// The proxied branch validates the requested URL before construction and
|
||||
// on every redirect. Its TCP peer is the selected proxy, so applying the
|
||||
// direct-origin IP guard there would incorrectly reject trusted loopback or
|
||||
// private-network proxies. Freeze the selected proxy in request context so
|
||||
// a stateful selector cannot switch the second lookup to direct egress.
|
||||
proxied := cloneDownloadHTTPTransport(source)
|
||||
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
|
||||
if !ok || selected == nil {
|
||||
return nil, fmt.Errorf("download proxy selection is missing")
|
||||
}
|
||||
cloned := *selected
|
||||
return &cloned, nil
|
||||
}
|
||||
return &proxyAwareDownloadTransport{
|
||||
selectProxy: selectProxy,
|
||||
direct: direct,
|
||||
proxied: proxied,
|
||||
lookupIP: lookupIP,
|
||||
proxiedByTLSServer: make(map[string]*http.Transport),
|
||||
}, true
|
||||
}
|
||||
|
||||
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download request URL is missing")
|
||||
}
|
||||
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
|
||||
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
|
||||
}
|
||||
|
||||
originalHost := req.URL.Host
|
||||
pinnedHost := targetIP.String()
|
||||
if port := req.URL.Port(); port != "" {
|
||||
pinnedHost = net.JoinHostPort(pinnedHost, port)
|
||||
} else if strings.Contains(pinnedHost, ":") {
|
||||
pinnedHost = "[" + pinnedHost + "]"
|
||||
}
|
||||
|
||||
pinned := req.Clone(req.Context())
|
||||
pinnedURL := *req.URL
|
||||
pinnedURL.Host = pinnedHost
|
||||
pinned.URL = &pinnedURL
|
||||
pinned.Host = originalHost
|
||||
return pinned, nil
|
||||
}
|
||||
|
||||
func canRetryDownloadTarget(req *http.Request) bool {
|
||||
if req == nil || req.Body != nil {
|
||||
return false
|
||||
}
|
||||
return req.Method == http.MethodGet || req.Method == http.MethodHead
|
||||
}
|
||||
|
||||
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
|
||||
if config == nil {
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return config.Clone()
|
||||
}
|
||||
|
||||
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
|
||||
if transport.DialTLSContext != nil || transport.DialTLS != nil {
|
||||
return
|
||||
}
|
||||
|
||||
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
|
||||
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := proxyTLSConfig.Clone()
|
||||
serverName, _, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
|
||||
}
|
||||
config.ServerName = serverName
|
||||
tlsConn := tls.Client(rawConn, config)
|
||||
handshakeCtx := ctx
|
||||
cancel := func() {}
|
||||
if source.TLSHandshakeTimeout > 0 {
|
||||
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
||||
rawConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
|
||||
if source.DialContext != nil {
|
||||
return source.DialContext(ctx, network, addr)
|
||||
}
|
||||
if source.Dial != nil {
|
||||
return source.Dial(network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialConn(ctx, origDial, network, addr)
|
||||
@@ -473,7 +158,7 @@ func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -487,26 +172,13 @@ func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
if cloned.DialTLS != nil {
|
||||
origDialTLS := cloned.DialTLS
|
||||
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
|
||||
conn, err := origDialTLS(network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
||||
@@ -522,7 +194,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -536,14 +208,6 @@ func dialConn(ctx context.Context, dialFn func(context.Context, string, string)
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func downloadTargetPolicyError(err error) error {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
func validateConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return fmt.Errorf("nil connection")
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestProxiedHTTPSDownloadPinsValidatedTargetIP(t *testing.T) {
|
||||
const (
|
||||
targetHost = "rebind.example"
|
||||
targetIP = "203.0.113.10"
|
||||
)
|
||||
|
||||
proxyCalled := make(chan struct{}, 1)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
proxyCalled <- struct{}{}
|
||||
if req.Method != http.MethodConnect {
|
||||
t.Errorf("proxy request method = %q, want CONNECT", req.Method)
|
||||
}
|
||||
if got := req.Host; got != targetIP+":443" {
|
||||
t.Errorf("proxy CONNECT target = %q, want validated IP %q", got, targetIP+":443")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP(targetIP)}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://"+targetHost+"/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned, err := pinDownloadRequestTargetToIP(req, net.ParseIP(targetIP))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pinned.Host != targetHost {
|
||||
t.Fatalf("pinned request Host = %q, want %q", pinned.Host, targetHost)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection after CONNECT")
|
||||
}
|
||||
select {
|
||||
case <-proxyCalled:
|
||||
default:
|
||||
t.Fatal("proxy was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedDownloadIPBlocksReservedIPv4(t *testing.T) {
|
||||
for _, rawIP := range []string{"0.1.2.3", "240.0.0.1"} {
|
||||
if !isRestrictedDownloadIP(net.ParseIP(rawIP)) {
|
||||
t.Fatalf("%s was classified as safe", rawIP)
|
||||
}
|
||||
}
|
||||
if isRestrictedDownloadIP(net.ParseIP("1.1.1.1")) {
|
||||
t.Fatal("1.1.1.1 was classified as restricted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTLSConfigSetsMinimumVersion(t *testing.T) {
|
||||
if got := cloneDownloadTLSConfig(nil).MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %d, want TLS 1.2", got)
|
||||
}
|
||||
configured := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
if got := cloneDownloadTLSConfig(configured).MinVersion; got != tls.VersionTLS13 {
|
||||
t.Fatalf("cloned MinVersion = %d, want TLS 1.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadHTTPTransportPreservesHTTP2Policy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source *http.Transport
|
||||
wantForce bool
|
||||
wantH2Handler bool
|
||||
wantProtocolMap bool
|
||||
}{
|
||||
{
|
||||
name: "automatic",
|
||||
source: &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "custom TLS without opt-in",
|
||||
source: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom dial without opt-in",
|
||||
source: &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, errors.New("unused")
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit opt-in",
|
||||
source: &http.Transport{ForceAttemptHTTP2: true},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "explicit h2 handler",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{
|
||||
"h2": func(string, *tls.Conn) http.RoundTripper { return nil },
|
||||
},
|
||||
},
|
||||
wantH2Handler: true,
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
{
|
||||
name: "explicit opt-out",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||
},
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cloned := cloneDownloadHTTPTransport(test.source)
|
||||
if cloned.ForceAttemptHTTP2 != test.wantForce {
|
||||
t.Fatalf("ForceAttemptHTTP2 = %v, want %v", cloned.ForceAttemptHTTP2, test.wantForce)
|
||||
}
|
||||
_, hasH2Handler := cloned.TLSNextProto["h2"]
|
||||
if hasH2Handler != test.wantH2Handler {
|
||||
t.Fatalf("h2 handler = %v, want %v", hasH2Handler, test.wantH2Handler)
|
||||
}
|
||||
if hasProtocolMap := cloned.TLSNextProto != nil; hasProtocolMap != test.wantProtocolMap {
|
||||
t.Fatalf("TLSNextProto is non-nil = %v, want %v", hasProtocolMap, test.wantProtocolMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTransportPreservesAutomaticHTTP2(t *testing.T) {
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
rebuilt := cloneDownloadTransport(source)
|
||||
proxyAware, ok := rebuilt.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", rebuilt)
|
||||
}
|
||||
direct, ok := proxyAware.direct.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("direct transport type = %T, want *http.Transport", proxyAware.direct)
|
||||
}
|
||||
for name, transport := range map[string]*http.Transport{
|
||||
"direct": direct,
|
||||
"proxied": proxyAware.proxied,
|
||||
} {
|
||||
if !transport.ForceAttemptHTTP2 {
|
||||
t.Fatalf("%s ForceAttemptHTTP2 = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedDownloadRejectsRestrictedResolvedTarget(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://rebind.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want restricted target rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a restricted resolved target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedPlainHTTPHostnameRejectsLocalProxy(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(ctx context.Context, _, host string) ([]net.IP, error) {
|
||||
if host == "public.example" {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
}
|
||||
return net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want plain HTTP hostname rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
} else if problem.Hint != "use HTTPS or a literal public IP" {
|
||||
t.Fatalf("RoundTrip() hint = %q, want recovery guidance", problem.Hint)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a plain HTTP hostname target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSDownloadTriesEveryValidatedTargetIP(t *testing.T) {
|
||||
connectTargets := make(chan string, 2)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
connectTargets <- req.Host
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{
|
||||
net.ParseIP("203.0.113.10"),
|
||||
net.ParseIP("203.0.113.11"),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://multi.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection")
|
||||
}
|
||||
for _, want := range []string{"203.0.113.10:443", "203.0.113.11:443"} {
|
||||
select {
|
||||
case got := <-connectTargets:
|
||||
if got != want {
|
||||
t.Fatalf("proxy CONNECT target = %q, want %q", got, want)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("proxy did not receive CONNECT target %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanRetryDownloadTargetOnlyAllowsBodylessReads(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{method: http.MethodGet, want: true},
|
||||
{method: http.MethodHead, want: true},
|
||||
{method: http.MethodPost},
|
||||
{method: http.MethodGet, body: "body"},
|
||||
} {
|
||||
req, err := http.NewRequest(test.method, "https://download.example/file", strings.NewReader(test.body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.body == "" {
|
||||
req.Body = nil
|
||||
}
|
||||
if got := canRetryDownloadTarget(req); got != test.want {
|
||||
t.Fatalf("canRetryDownloadTarget(%s, body=%q) = %v, want %v", test.method, test.body, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSTargetPreservesOriginalTLSServerName(t *testing.T) {
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"})},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
proxyAware, ok := transport.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", transport)
|
||||
}
|
||||
|
||||
pinned := proxyAware.proxiedTransportForTLSServer("download.example")
|
||||
if pinned.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil")
|
||||
}
|
||||
if pinned.TLSClientConfig.ServerName != "download.example" {
|
||||
t.Fatalf("TLS ServerName = %q, want download.example", pinned.TLSClientConfig.ServerName)
|
||||
}
|
||||
if proxyAware.proxied.TLSClientConfig != nil && proxyAware.proxied.TLSClientConfig.ServerName != "" {
|
||||
t.Fatalf("base proxy TLS ServerName = %q, want unchanged", proxyAware.proxied.TLSClientConfig.ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesLegacyDial(t *testing.T) {
|
||||
wantErr := errors.New("legacy dial used")
|
||||
source := &http.Transport{
|
||||
Dial: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
if target.DialTLSContext == nil {
|
||||
t.Fatal("DialTLSContext = nil")
|
||||
}
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLSContext() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSClosesRestrictedConnection(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() { serverConn.Close() })
|
||||
conn := &trackedDownloadConn{
|
||||
Conn: clientConn,
|
||||
remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443},
|
||||
}
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
_, err := transport.DialTLS("tcp", "public.example:443")
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("DialTLS() error = %v, want restricted target rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DialTLS() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !conn.closed {
|
||||
t.Fatal("restricted connection was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSPreservesDialError(t *testing.T) {
|
||||
wantErr := errors.New("dial failed")
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
if _, err := transport.DialTLS("tcp", "public.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLS() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerRetainsHandshakeTimeout(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSHandshakeTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
started := time.Now()
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); err == nil {
|
||||
t.Fatal("DialTLSContext() error = nil, want TLS handshake timeout")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("TLS handshake timeout took %s, want under 1s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesProxyServerName(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
|
||||
proxySNI := make(chan string, 1)
|
||||
serverTLSConfig := server.TLS.Clone()
|
||||
serverTLSConfig.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
proxySNI <- info.ServerName
|
||||
return nil, nil
|
||||
}
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- tls.Server(serverConn, serverTLSConfig).Handshake()
|
||||
}()
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(server.Certificate())
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "target.example.com",
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
conn, err := target.DialTLSContext(context.Background(), "tcp", "example.com:443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if err := <-serverErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-proxySNI; got != "example.com" {
|
||||
t.Fatalf("proxy TLS ServerName = %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
type trackedDownloadConn struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) Close() error {
|
||||
c.closed = true
|
||||
return c.Conn.Close()
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type opaqueRoundTripper struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *opaqueRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.called = true
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
type downloadTestProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p downloadTestProvider) Name() string { return "download-test" }
|
||||
|
||||
func (p downloadTestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type downloadHeaderInterceptor struct{}
|
||||
|
||||
func (downloadHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set("X-Use-Proxy", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientPreservesPolicyRouterBaseTransport(t *testing.T) {
|
||||
wantErr := errors.New("proxy policy blocked request")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Transport.RoundTrip(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want preserved proxy error %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientRejectsInitialHTTPBeforeTransport(t *testing.T) {
|
||||
base := &opaqueRoundTripper{}
|
||||
download := validate.NewDownloadHTTPClient(
|
||||
&http.Client{Transport: base},
|
||||
validate.DownloadHTTPClientOptions{},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want initial HTTP rejection")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if base.called {
|
||||
t.Fatal("base transport was called for a disallowed initial HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientAllowsSelectedLoopbackProxy(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Host != "203.0.113.10" {
|
||||
t.Errorf("proxy request target = %q, want 203.0.113.10", req.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through selected loopback proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientSelectsProxyAfterOuterDecorators(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if got := req.Header.Get("X-Use-Proxy"); got != "1" {
|
||||
t.Errorf("proxy received X-Use-Proxy = %q, want 1", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("proxy selector ran before decorators")
|
||||
base := &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) {
|
||||
if req.Header.Get("X-Use-Proxy") != "1" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return proxyURL, nil
|
||||
}}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(downloadTestProvider{interceptor: downloadHeaderInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through decorator-selected proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientGuardsLegacyDialTLS(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
|
||||
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
|
||||
}}
|
||||
download := validate.NewDownloadHTTPClient(&http.Client{Transport: base}, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
|
||||
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
|
||||
opaque := &opaqueRoundTripper{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: opaque},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
|
||||
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if opaque.called {
|
||||
t.Fatal("opaque transport was called after safe cloning failed")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ func SafeOutputPath(path string) (string, error) {
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
|
||||
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
|
||||
// load-bearing invariant. Out-of-tree content reaches flags via stdin ("-").
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
@@ -252,10 +252,11 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
// WHEN: SafeInputPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
// THEN: the strict validator rejects it — uploads / drive sync rely on
|
||||
// relative-only; out-of-tree content reaches flags via stdin ("-")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -75,9 +74,11 @@ func normalizeTimestamp(raw string) (string, error) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
|
||||
}
|
||||
|
||||
//nolint:forbidigo // Presigned transfers use the external HTTP policy.
|
||||
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。
|
||||
//
|
||||
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
|
||||
func newFileTransferClient() *http.Client {
|
||||
return transport.NewExternalHTTPClient(0)
|
||||
return &http.Client{Transport: http.DefaultTransport}
|
||||
}
|
||||
|
||||
// URL helpers for the file (storage) CLI commands.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type appsExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p appsExternalProvider) Name() string { return "apps-external-test" }
|
||||
|
||||
func (p appsExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (appsExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type appsExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *appsExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
type appsRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f appsRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestFileTransferClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &appsExternalInterceptor{}
|
||||
exttransport.Register(appsExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = appsRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/presigned/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func TestShortcutsCatalog(t *testing.T) {
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", "+table-copy", "+table-copy-status",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
|
||||
|
||||
@@ -5,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -387,10 +386,6 @@ func baseV3Path(parts ...string) string {
|
||||
}
|
||||
|
||||
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
return baseV3RawContext(runtime.Ctx(), runtime, method, path, params, data)
|
||||
}
|
||||
|
||||
func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
for k, v := range params {
|
||||
switch val := v.(type) {
|
||||
@@ -414,7 +409,7 @@ func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, metho
|
||||
}
|
||||
h := make(http.Header)
|
||||
h.Set("X-App-Id", runtime.Config.AppID)
|
||||
resp, err := runtime.DoAPIWithContext(ctx, req, larkcore.WithHeaders(h))
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
|
||||
if err != nil {
|
||||
return nil, baseAPIBoundaryError(err, "API call failed")
|
||||
}
|
||||
@@ -509,11 +504,6 @@ func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
}
|
||||
|
||||
func baseV3CallContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
result, err := baseV3RawContext(ctx, runtime, method, path, params, data)
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
}
|
||||
|
||||
func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) {
|
||||
result, err := baseV3Raw(runtime, method, path, params, data)
|
||||
return handleBaseAPIResultAny(result, err, "API call failed")
|
||||
|
||||
@@ -20,8 +20,6 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseTableCreate,
|
||||
BaseTableUpdate,
|
||||
BaseTableDelete,
|
||||
BaseTableCopy,
|
||||
BaseTableCopyStatus,
|
||||
BaseFieldList,
|
||||
BaseFieldGet,
|
||||
BaseFieldCreate,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
tableCopyRangeSchema = "schema"
|
||||
tableCopyRangeAll = "all"
|
||||
tableCopyScope = "base:table:create"
|
||||
tableCopyTimeoutMax = 30 * time.Minute
|
||||
tableCopyTaskIDMax = 1024
|
||||
)
|
||||
|
||||
var BaseTableCopy = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+table-copy",
|
||||
Description: "Copy a table by ID or name; structure only by default",
|
||||
Risk: "write",
|
||||
Scopes: []string{tableCopyScope},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "name", Desc: "target table name", Required: true},
|
||||
{Name: "range", Default: tableCopyRangeSchema, Desc: "copy range; defaults to schema, use all only to include records", Enum: []string{tableCopyRangeSchema, tableCopyRangeAll}},
|
||||
{Name: "wait", Type: "bool", Desc: "wait for an all-range copy task to finish"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +table-copy --base-token <base_token> --table-id "Tasks" --name "Tasks copy"`,
|
||||
"table-id accepts a table ID or name in the current Base.",
|
||||
"The default copies schema only; use --range all only when records must also be copied.",
|
||||
"Use --wait with --range all to wait locally; otherwise continue with the returned next_command.",
|
||||
},
|
||||
DryRun: dryRunTableCopy,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.Flags().Duration("timeout", 5*time.Minute, "maximum time to wait for an asynchronous copy task (max 30m)")
|
||||
},
|
||||
Validate: validateTableCopy,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableCopy(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTableCopyStatus = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+table-copy-status",
|
||||
Description: "Get one table copy task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{tableCopyScope},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "task-id", Desc: "opaque table copy task ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Use the opaque task_id returned by base +table-copy; this command queries status once.",
|
||||
"If state is init or process, run the returned next_command later.",
|
||||
},
|
||||
DryRun: dryRunTableCopyStatus,
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
if strings.TrimSpace(taskID) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id cannot be blank").WithParam("--task-id")
|
||||
}
|
||||
if len(taskID) > tableCopyTaskIDMax {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id must not exceed %d bytes", tableCopyTaskIDMax).WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableCopyStatus(ctx, runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func validateTableCopy(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("table-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table-id cannot be blank").WithParam("--table-id")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name cannot be blank").WithParam("--name")
|
||||
}
|
||||
|
||||
rangeValue := runtime.Str("range")
|
||||
wait := runtime.Bool("wait")
|
||||
timeoutChanged := runtime.Changed("timeout")
|
||||
if rangeValue == tableCopyRangeSchema {
|
||||
if wait {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wait requires --range all").WithParam("--wait")
|
||||
}
|
||||
if timeoutChanged {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --range all and --wait").WithParam("--timeout")
|
||||
}
|
||||
}
|
||||
if timeoutChanged && !wait {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --wait").WithParam("--timeout")
|
||||
}
|
||||
timeout, err := tableCopyTimeout(runtime)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", err).WithParam("--timeout").WithCause(err)
|
||||
}
|
||||
if wait && (timeout <= 0 || timeout > tableCopyTimeoutMax) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout must be greater than 0 and at most 30m").WithParam("--timeout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableCopyTimeout(runtime *common.RuntimeContext) (time.Duration, error) {
|
||||
return runtime.Cmd.Flags().GetDuration("timeout")
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
tableCopyStateInit = "init"
|
||||
tableCopyStateProcess = "process"
|
||||
tableCopyStateSuccess = "success"
|
||||
)
|
||||
|
||||
type tableCopyTable struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type tableCopySubmitResult struct {
|
||||
Table tableCopyTable
|
||||
TaskID string
|
||||
State string
|
||||
}
|
||||
|
||||
type tableCopyStatus struct {
|
||||
TableID string
|
||||
State string
|
||||
}
|
||||
|
||||
type tableCopyOutput struct {
|
||||
Table tableCopyTable `json:"table"`
|
||||
Range string `json:"range,omitempty"`
|
||||
State string `json:"state"`
|
||||
Completed bool `json:"completed"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
TimedOut bool `json:"timed_out,omitempty"`
|
||||
NextAction string `json:"next_action,omitempty"`
|
||||
NextCommand string `json:"next_command,omitempty"`
|
||||
}
|
||||
|
||||
func dryRunTableCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
baseToken := runtime.Str("base-token")
|
||||
rangeValue := runtime.Str("range")
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/copy").
|
||||
Desc("[1] Submit table copy").
|
||||
Body(map[string]interface{}{
|
||||
"name": runtime.Str("name"),
|
||||
"range": rangeValue,
|
||||
}).
|
||||
Set("base_token", baseToken).
|
||||
Set("table_id", runtime.Str("table-id"))
|
||||
if runtime.Bool("wait") {
|
||||
dry.POST("/open-apis/base/v3/bases/:base_token/copy_table_state").
|
||||
Desc("[2] Poll with 3s exponential backoff, capped at 30s").
|
||||
Body(map[string]interface{}{"task_id": "<task_id_from_step_1>"})
|
||||
timeout, _ := tableCopyTimeout(runtime)
|
||||
dry.Set("wait", true).Set("timeout", timeout.String())
|
||||
} else {
|
||||
dry.Set("wait", false)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
func dryRunTableCopyStatus(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/copy_table_state").
|
||||
Body(map[string]interface{}{"task_id": runtime.Str("task-id")}).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func executeTableCopy(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeTableCopyWithClock(ctx, runtime, realTableCopyClock{})
|
||||
}
|
||||
|
||||
func executeTableCopyWithClock(ctx context.Context, runtime *common.RuntimeContext, clock tableCopyClock) error {
|
||||
rangeValue := runtime.Str("range")
|
||||
submit, err := submitTableCopy(runtime, rangeValue)
|
||||
if err != nil {
|
||||
return tableCopySubmissionError(err)
|
||||
}
|
||||
if rangeValue == tableCopyRangeSchema {
|
||||
if submit.State != tableCopyStateSuccess {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "schema table copy returned non-success state %q", submit.State)
|
||||
}
|
||||
runtime.Out(tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: submit.State,
|
||||
Completed: true,
|
||||
}, nil)
|
||||
tableCopyProgressf(runtime, "Table copy completed: success")
|
||||
return nil
|
||||
}
|
||||
if submit.State == tableCopyStateSuccess {
|
||||
runtime.Out(tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: submit.State,
|
||||
Completed: true,
|
||||
TaskID: submit.TaskID,
|
||||
}, nil)
|
||||
tableCopyProgressf(runtime, "Table copy completed: success")
|
||||
return nil
|
||||
}
|
||||
if submit.TaskID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "all-range table copy response missing task_id")
|
||||
}
|
||||
if runtime.Bool("wait") {
|
||||
tableCopyProgressf(runtime, "Table copy submitted: %s, task_id=%s", submit.State, submit.TaskID)
|
||||
timeout, timeoutErr := tableCopyTimeout(runtime)
|
||||
if timeoutErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", timeoutErr).WithParam("--timeout").WithCause(timeoutErr)
|
||||
}
|
||||
stopSpinner := runtime.StartSpinner("Waiting for table copy")
|
||||
status, timedOut, pollErr := pollTableCopy(ctx, timeout, clock, func(ctx context.Context) (tableCopyStatus, error) {
|
||||
status, err := queryTableCopyStatus(ctx, runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
if err != nil {
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
tableCopyProgressf(runtime, "Table copy status query error: %s/%s", problem.Category, problem.Subtype)
|
||||
} else {
|
||||
tableCopyProgressf(runtime, "Table copy status query error")
|
||||
}
|
||||
return tableCopyStatus{}, err
|
||||
}
|
||||
tableCopyProgressf(runtime, "Table copy status: %s", status.State)
|
||||
return status, nil
|
||||
})
|
||||
stopSpinner()
|
||||
if pollErr != nil {
|
||||
recoveryState := status.State
|
||||
if recoveryState == "" {
|
||||
recoveryState = submit.State
|
||||
}
|
||||
recovery := tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: recoveryState,
|
||||
Completed: false,
|
||||
TaskID: submit.TaskID,
|
||||
}
|
||||
if tableCopyWaitCanContinue(pollErr) {
|
||||
recovery.NextAction = "poll_status"
|
||||
recovery.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
}
|
||||
recoveryErr := runtime.OutPartialFailure(recovery, nil)
|
||||
var partialFailure *output.PartialFailureError
|
||||
if !errors.As(recoveryErr, &partialFailure) {
|
||||
return recoveryErr
|
||||
}
|
||||
return tableCopyWaitError(pollErr)
|
||||
}
|
||||
if timedOut && status.State == "" {
|
||||
// No status query completed before the deadline. The submit response
|
||||
// is still the last known task state, so preserve it.
|
||||
status.State = submit.State
|
||||
}
|
||||
out := tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: status.State,
|
||||
Completed: status.State == tableCopyStateSuccess,
|
||||
TaskID: submit.TaskID,
|
||||
TimedOut: timedOut,
|
||||
}
|
||||
if !out.Completed {
|
||||
out.NextAction = "poll_status"
|
||||
out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
tableCopyProgressf(runtime, "Table copy is not complete; use next_command from stdout to continue")
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
out := tableCopyOutput{
|
||||
Table: submit.Table,
|
||||
Range: rangeValue,
|
||||
State: submit.State,
|
||||
Completed: submit.State == tableCopyStateSuccess,
|
||||
TaskID: submit.TaskID,
|
||||
}
|
||||
if !out.Completed {
|
||||
out.NextAction = "poll_status"
|
||||
out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
|
||||
tableCopyProgressf(runtime, "Table copy is running asynchronously; use next_command from stdout to continue")
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableCopySubmissionError(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork {
|
||||
return err
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeNetworkTimeout && problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
return err
|
||||
}
|
||||
problem.Message = "table copy submission outcome is unknown because the response was not received"
|
||||
problem.Hint = "Do not retry the copy automatically. Manually confirm whether the target table was created before deciding the next action."
|
||||
problem.Retryable = false
|
||||
return err
|
||||
}
|
||||
|
||||
func tableCopyWaitCanContinue(err error) bool {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
switch problem.Category {
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return tableCopyPollErrorRetryable(err)
|
||||
}
|
||||
|
||||
func tableCopyWaitError(err error) error {
|
||||
if !tableCopyWaitCanContinue(err) {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
hint := "The copy task was already submitted; do not submit it again. Read task_id from the submit output and continue with lark-cli base +table-copy-status using the same identity."
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "table copy status polling was canceled").WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "table copy status polling timed out").WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
if problem.Hint == "" {
|
||||
problem.Hint = hint
|
||||
} else {
|
||||
problem.Hint += " " + hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithHint("%s", hint).WithCause(err)
|
||||
}
|
||||
|
||||
func executeTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
taskID := runtime.Str("task-id")
|
||||
status, err := queryTableCopyStatus(ctx, runtime, baseToken, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := tableCopyOutput{
|
||||
Table: tableCopyTable{ID: status.TableID},
|
||||
State: status.State,
|
||||
Completed: status.State == tableCopyStateSuccess,
|
||||
TaskID: taskID,
|
||||
}
|
||||
if !out.Completed {
|
||||
out.NextAction = "poll_status"
|
||||
out.NextCommand = tableCopyNextCommand(runtime, baseToken, taskID)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
tableCopyProgressf(runtime, "Table copy status: %s", status.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
func submitTableCopy(runtime *common.RuntimeContext, rangeValue string) (tableCopySubmitResult, error) {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableRef := runtime.Str("table-id")
|
||||
body := map[string]interface{}{
|
||||
"name": runtime.Str("name"),
|
||||
"range": rangeValue,
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableRef, "copy"), nil, body)
|
||||
if err != nil {
|
||||
return tableCopySubmitResult{}, err
|
||||
}
|
||||
return projectTableCopySubmit(data)
|
||||
}
|
||||
|
||||
func projectTableCopySubmit(data map[string]interface{}) (tableCopySubmitResult, error) {
|
||||
tableData := common.GetMap(data, "table")
|
||||
result := tableCopySubmitResult{
|
||||
Table: tableCopyTable{
|
||||
ID: strings.TrimSpace(common.GetString(tableData, "id")),
|
||||
Name: common.GetString(tableData, "name"),
|
||||
},
|
||||
TaskID: strings.TrimSpace(common.GetString(data, "task_id")),
|
||||
State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))),
|
||||
}
|
||||
if result.Table.ID == "" {
|
||||
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response missing table.id")
|
||||
}
|
||||
if len(result.TaskID) > tableCopyTaskIDMax {
|
||||
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response task_id exceeds %d bytes", tableCopyTaskIDMax)
|
||||
}
|
||||
switch result.State {
|
||||
case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess:
|
||||
return result, nil
|
||||
default:
|
||||
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response has invalid state %q", result.State)
|
||||
}
|
||||
}
|
||||
|
||||
func queryTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext, baseToken, taskID string) (tableCopyStatus, error) {
|
||||
data, err := baseV3CallContext(
|
||||
ctx,
|
||||
runtime,
|
||||
"POST",
|
||||
baseV3Path("bases", baseToken, "copy_table_state"),
|
||||
nil,
|
||||
map[string]interface{}{"task_id": taskID},
|
||||
)
|
||||
if err != nil {
|
||||
return tableCopyStatus{}, tableCopyStatusError(err)
|
||||
}
|
||||
return projectTableCopyStatus(data)
|
||||
}
|
||||
|
||||
func tableCopyStatusError(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Code != 800010109 {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
validationErr.WithParam("--task-id")
|
||||
return err
|
||||
}
|
||||
classified := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", problem.Message).
|
||||
WithParam("--task-id").
|
||||
WithCode(problem.Code).
|
||||
WithCause(err)
|
||||
if problem.Hint != "" {
|
||||
classified.WithHint("%s", problem.Hint)
|
||||
}
|
||||
if problem.LogID != "" {
|
||||
classified.WithLogID(problem.LogID)
|
||||
}
|
||||
return classified
|
||||
}
|
||||
|
||||
func projectTableCopyStatus(data map[string]interface{}) (tableCopyStatus, error) {
|
||||
status := tableCopyStatus{
|
||||
TableID: strings.TrimSpace(common.GetString(data, "table_id")),
|
||||
State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))),
|
||||
}
|
||||
if status.TableID == "" {
|
||||
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response missing table_id")
|
||||
}
|
||||
switch status.State {
|
||||
case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess:
|
||||
return status, nil
|
||||
case "failed":
|
||||
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status returned state=failed in a success envelope; the API must return task failures through the top-level error protocol")
|
||||
default:
|
||||
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response has invalid state %q", status.State)
|
||||
}
|
||||
}
|
||||
|
||||
func tableCopyNextCommand(runtime *common.RuntimeContext, baseToken, taskID string) string {
|
||||
parts := []string{"lark-cli"}
|
||||
if runtime.Cmd.Flags().Lookup("profile") != nil && runtime.Changed("profile") {
|
||||
profile, _ := runtime.Cmd.Flags().GetString("profile")
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
parts = append(parts, "--profile", tableCopyShellArg(profile))
|
||||
}
|
||||
}
|
||||
parts = append(parts,
|
||||
"base", "+table-copy-status",
|
||||
"--base-token", tableCopyShellArg(baseToken),
|
||||
"--task-id", tableCopyShellArg(taskID),
|
||||
"--as", string(runtime.As()),
|
||||
)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func tableCopyShellArg(value string) string {
|
||||
if value != "" && strings.IndexFunc(value, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && !strings.ContainsRune("._~-", r)
|
||||
}) == -1 {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
func tableCopyProgressf(runtime *common.RuntimeContext, format string, args ...interface{}) {
|
||||
if runtime == nil || runtime.IO() == nil || runtime.IO().ErrOut == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, format+"\n", args...)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
tableCopyPollInitial = 3 * time.Second
|
||||
tableCopyPollMax = 30 * time.Second
|
||||
)
|
||||
|
||||
type tableCopyTimer interface {
|
||||
C() <-chan time.Time
|
||||
Stop() bool
|
||||
}
|
||||
|
||||
type tableCopyClock interface {
|
||||
Now() time.Time
|
||||
NewTimer(time.Duration) tableCopyTimer
|
||||
}
|
||||
|
||||
type tableCopyStatusFetcher func(context.Context) (tableCopyStatus, error)
|
||||
|
||||
type realTableCopyClock struct{}
|
||||
|
||||
func (realTableCopyClock) Now() time.Time { return time.Now() }
|
||||
|
||||
func (realTableCopyClock) NewTimer(duration time.Duration) tableCopyTimer {
|
||||
return realTableCopyTimer{Timer: time.NewTimer(duration)}
|
||||
}
|
||||
|
||||
type realTableCopyTimer struct {
|
||||
*time.Timer
|
||||
}
|
||||
|
||||
func (t realTableCopyTimer) C() <-chan time.Time { return t.Timer.C }
|
||||
|
||||
func pollTableCopy(
|
||||
ctx context.Context,
|
||||
timeout time.Duration,
|
||||
clock tableCopyClock,
|
||||
fetch tableCopyStatusFetcher,
|
||||
) (tableCopyStatus, bool, error) {
|
||||
deadline := clock.Now().Add(timeout)
|
||||
delay := tableCopyPollInitial
|
||||
var lastStatus tableCopyStatus
|
||||
var lastErr error
|
||||
hasStatus := false
|
||||
|
||||
for {
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
if !hasStatus && lastErr != nil {
|
||||
return tableCopyStatus{}, false, lastErr
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
if delay > remaining {
|
||||
delay = remaining
|
||||
}
|
||||
|
||||
timer := clock.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return lastStatus, false, ctx.Err()
|
||||
case <-timer.C():
|
||||
}
|
||||
|
||||
if !clock.Now().Before(deadline) {
|
||||
if !hasStatus && lastErr != nil {
|
||||
return tableCopyStatus{}, false, lastErr
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
requestBudget := deadline.Sub(clock.Now())
|
||||
if requestBudget <= 0 {
|
||||
if !hasStatus && lastErr != nil {
|
||||
return tableCopyStatus{}, false, lastErr
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
fetchCtx, cancelFetch := context.WithTimeout(ctx, requestBudget)
|
||||
status, err := fetch(fetchCtx)
|
||||
cancelFetch()
|
||||
if ctx.Err() != nil {
|
||||
return lastStatus, false, ctx.Err()
|
||||
}
|
||||
if !clock.Now().Before(deadline) {
|
||||
if !hasStatus && err != nil {
|
||||
return tableCopyStatus{}, false, err
|
||||
}
|
||||
return lastStatus, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
if !tableCopyPollErrorRetryable(err) {
|
||||
return lastStatus, false, err
|
||||
}
|
||||
lastErr = err
|
||||
} else {
|
||||
lastStatus = status
|
||||
hasStatus = true
|
||||
switch status.State {
|
||||
case tableCopyStateSuccess:
|
||||
return status, false, nil
|
||||
case tableCopyStateInit, tableCopyStateProcess:
|
||||
default:
|
||||
return lastStatus, false, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status has invalid state %q", status.State)
|
||||
}
|
||||
}
|
||||
|
||||
delay *= 2
|
||||
if delay > tableCopyPollMax {
|
||||
delay = tableCopyPollMax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tableCopyPollErrorRetryable(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if problem.Category == errs.CategoryNetwork {
|
||||
switch problem.Subtype {
|
||||
case errs.SubtypeNetworkTimeout, errs.SubtypeNetworkTransport, errs.SubtypeNetworkServer:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return problem.Category == errs.CategoryAPI && problem.Retryable
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ type RuntimeContext struct {
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -450,12 +451,6 @@ func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interfa
|
||||
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
|
||||
// the identity → token logic across the generic and shortcut API paths.
|
||||
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
|
||||
}
|
||||
|
||||
// DoAPIWithContext executes a raw Lark SDK request using callCtx for request
|
||||
// cancellation and deadlines while preserving the shortcut's resolved identity.
|
||||
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
ac, err := ctx.getAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -463,7 +458,7 @@ func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkco
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...)
|
||||
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
|
||||
}
|
||||
|
||||
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
|
||||
@@ -1022,6 +1017,25 @@ func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// InputResolvedFromSource reports whether the named flag's value was loaded
|
||||
// from an external source (@file or stdin `-`) by resolveInputFlags, as
|
||||
// opposed to typed inline on the command line. Domain guards that apply
|
||||
// shape heuristics to inline values ("this looks like a file path — did you
|
||||
// forget the @?") must skip resolved values: their content was already read
|
||||
// from the right place and may legitimately look like anything, including a
|
||||
// path. Without this bit such a guard re-rejects correct @file / stdin
|
||||
// invocations, because by the time Validate runs both arrive as plain text.
|
||||
func (ctx *RuntimeContext) InputResolvedFromSource(name string) bool {
|
||||
return ctx.inputResolved[name]
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) markInputResolved(name string) {
|
||||
if ctx.inputResolved == nil {
|
||||
ctx.inputResolved = map[string]bool{}
|
||||
}
|
||||
ctx.inputResolved[name] = true
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
@@ -1061,6 +1075,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
rctx.markInputResolved(fl.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1097,6 +1112,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
rctx.markInputResolved(fl.Name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestResolveInputFlags_DirectValue(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != "hello world" {
|
||||
t.Errorf("expected %q, got %q", "hello world", got)
|
||||
}
|
||||
if rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("inline value must not be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
@@ -55,6 +58,9 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != "content from stdin" {
|
||||
t.Errorf("expected %q, got %q", "content from stdin", got)
|
||||
}
|
||||
if !rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("stdin value should be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_File(t *testing.T) {
|
||||
@@ -75,6 +81,27 @@ func TestResolveInputFlags_File(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != content {
|
||||
t.Errorf("expected %q, got %q", content, got)
|
||||
}
|
||||
if !rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("@file value should be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInputFlags_EscapedAtStaysInline pins that the @@ escape is
|
||||
// inline content (a literal leading @), not an external source — heuristic
|
||||
// guards keyed on InputResolvedFromSource must still see it.
|
||||
func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@@handle"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("markdown"); got != "@handle" {
|
||||
t.Errorf("expected %q, got %q", "@handle", got)
|
||||
}
|
||||
if rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("escaped @@ value must not be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyFile(t *testing.T) {
|
||||
|
||||
@@ -39,6 +39,13 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i
|
||||
return rctx
|
||||
}
|
||||
|
||||
// TestMarkInputResolved marks a flag as resolved from @file / stdin, so
|
||||
// domain tests can exercise guards that branch on InputResolvedFromSource
|
||||
// without wiring the full resolveInputFlags path.
|
||||
func TestMarkInputResolved(rctx *RuntimeContext, name string) {
|
||||
rctx.markInputResolved(name)
|
||||
}
|
||||
|
||||
// TestNewRuntimeContextForAPI creates a RuntimeContext ready for HTTP tests:
|
||||
// sets Cmd, Config, Factory, context, and the requested identity so callers
|
||||
// can invoke DoAPI / CallAPI directly without wiring through a cobra parent
|
||||
|
||||
@@ -40,6 +40,12 @@ func (c docCoverHTTPStatusCause) Error() string {
|
||||
return http.StatusText(int(c))
|
||||
}
|
||||
|
||||
type docCoverURLGuardError string
|
||||
|
||||
func (e docCoverURLGuardError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var docCoverAllowedContentTypes = map[string]string{
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
@@ -536,7 +542,7 @@ func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, ra
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
baseClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -667,9 +673,6 @@ func isUnsafeDocCoverIP(ip net.IP) bool {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 0 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
@@ -698,15 +701,13 @@ func isUnsafeDocCoverIP(ip net.IP) bool {
|
||||
|
||||
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
|
||||
if base == nil {
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.ExternalHTTPClient.
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
|
||||
}
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
}
|
||||
cloned.Transport = validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{ //nolint:forbidigo // guarded external download
|
||||
MaxRedirects: 3,
|
||||
}).Transport
|
||||
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
|
||||
if len(via) >= 3 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
|
||||
@@ -722,3 +723,73 @@ func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
cloned.Proxy = nil
|
||||
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
if cloned.DialTLSContext != nil {
|
||||
origDialTLS := cloned.DialTLSContext
|
||||
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
if dialFn != nil {
|
||||
return dialFn(ctx, network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func validateDocCoverConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return docCoverURLGuardError("nil connection")
|
||||
}
|
||||
addr := conn.RemoteAddr()
|
||||
if addr == nil {
|
||||
return docCoverURLGuardError("missing remote address")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
host = addr.String()
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
return docCoverURLGuardError("invalid remote IP")
|
||||
}
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return docCoverURLGuardError("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -386,7 +386,6 @@ func TestValidateDocCoverURLHost(t *testing.T) {
|
||||
|
||||
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
for _, rawIP := range []string{
|
||||
"0.1.2.3",
|
||||
"10.0.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.1.1",
|
||||
@@ -407,26 +406,17 @@ func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientPreservesProxyPolicy(t *testing.T) {
|
||||
proxyErr := errors.New("proxy selected")
|
||||
directErr := errors.New("direct dialed")
|
||||
baseTransport := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, proxyErr
|
||||
},
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, directErr
|
||||
},
|
||||
}
|
||||
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
|
||||
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
baseClient := &http.Client{Transport: baseTransport}
|
||||
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://203.0.113.10/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if _, err := client.Transport.RoundTrip(req); !errors.Is(err, proxyErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want proxy policy error %v", err, proxyErr)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("cover URL downloader must not inherit proxy settings")
|
||||
}
|
||||
if baseTransport.Proxy == nil {
|
||||
t.Fatal("base transport proxy was mutated")
|
||||
@@ -456,6 +446,21 @@ func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
|
||||
if err := validateDocCoverConnRemoteIP(nil); err == nil {
|
||||
t.Fatal("expected nil connection error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
|
||||
t.Fatal("expected missing remote address error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
|
||||
t.Fatal("expected invalid remote IP error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
|
||||
t.Fatal("expected local remote IP error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverURLFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
@@ -657,6 +662,16 @@ func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
type testAddr string
|
||||
|
||||
func (a testAddr) Network() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
func (a testAddr) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
type repeatByteReader byte
|
||||
|
||||
func (r repeatByteReader) Read(p []byte) (int, error) {
|
||||
@@ -695,61 +710,3 @@ func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type opaqueDocCoverTransport struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *opaqueDocCoverTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.called = true
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
func TestNewDocCoverHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
|
||||
opaque := &opaqueDocCoverTransport{}
|
||||
client := newDocCoverHTTPClient(&http.Client{Transport: opaque})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
|
||||
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if opaque.called {
|
||||
t.Fatal("opaque transport was called after safe cloning failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDocCoverHTTPClientGuardsLegacyDialTLS(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
|
||||
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
|
||||
}}
|
||||
client := newDocCoverHTTPClient(&http.Client{Transport: base})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
|
||||
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -44,25 +43,6 @@ func (f shortcutRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, err
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type shortcutPolicyDecorator struct {
|
||||
base http.RoundTripper
|
||||
fn shortcutRoundTripFunc
|
||||
}
|
||||
|
||||
func (t *shortcutPolicyDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base
|
||||
}
|
||||
|
||||
func (t *shortcutPolicyDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *shortcutPolicyDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.fn(req)
|
||||
}
|
||||
|
||||
func shortcutJSONResponse(status int, body interface{}) *http.Response {
|
||||
b, _ := json.Marshal(body)
|
||||
return &http.Response{
|
||||
@@ -921,50 +901,6 @@ func TestStartURLDownloadBlockedURLCarriesParam(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartURLDownloadUsesExternalRequestClass(t *testing.T) {
|
||||
platform := &shortcutPolicyDecorator{
|
||||
base: http.DefaultTransport,
|
||||
fn: func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutRawResponse(http.StatusBadGateway, nil, nil), nil
|
||||
},
|
||||
}
|
||||
external := &shortcutPolicyDecorator{
|
||||
base: http.DefaultTransport,
|
||||
fn: func(req *http.Request) (*http.Response, error) {
|
||||
resp := shortcutRawResponse(http.StatusOK, []byte("image"), nil)
|
||||
resp.Request = req
|
||||
return resp, nil
|
||||
},
|
||||
}
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, _, err := startURLDownload(
|
||||
context.Background(),
|
||||
runtime,
|
||||
"https://open.feishu.cn/presigned/image.png",
|
||||
"--image",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("startURLDownload() error = %v, want external route", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(body); got != "image" {
|
||||
t.Fatalf("download body = %q, want external payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLocalMediaImage verifies that resolveLocalMedia can upload an image
|
||||
// via uploadImageToIM without double path validation.
|
||||
func TestResolveLocalMediaImage(t *testing.T) {
|
||||
|
||||
@@ -1790,7 +1790,7 @@ func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL strin
|
||||
return nil, mailInvalidResponseError("attachment download URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -398,36 +396,6 @@ func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAttachmentContentUsesExternalRequestClass(t *testing.T) {
|
||||
platform := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
external := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("attachment data")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
rt := newDownloadRuntime(t, &http.Client{
|
||||
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
|
||||
})
|
||||
|
||||
data, err := downloadAttachmentContent(rt, "https://open.feishu.cn/presigned/file")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadAttachmentContent() error = %v, want external route", err)
|
||||
}
|
||||
if got := string(data); got != "attachment data" {
|
||||
t.Fatalf("downloadAttachmentContent() = %q, want external payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// newOutputRuntime — helper for tests that call runtime.Out / runtime.IO()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -228,7 +228,7 @@ func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filenam
|
||||
return nil, "", mailInvalidResponseError("signature image download: URL has no host")
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -6,19 +6,15 @@ package mail
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
@@ -119,47 +115,6 @@ func TestContentTypeFromFilename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadSignatureImageUsesExternalRequestClass(t *testing.T) {
|
||||
const payload = `{"code":21000,"msg":"application-defined response","data":{"cli_hint":"external"}}`
|
||||
platform := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
external := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(payload)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
client := &http.Client{Transport: internaltransport.NewHTTPPolicyRouter(
|
||||
&auth.SecurityPolicyTransport{Base: platform},
|
||||
external,
|
||||
)}
|
||||
factory := &cmdutil.Factory{
|
||||
HttpClient: func() (*http.Client, error) { return client, nil },
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{}, nil)
|
||||
runtime.Factory = factory
|
||||
|
||||
data, contentType, err := downloadSignatureImage(
|
||||
runtime,
|
||||
"https://open.feishu.cn/signature.png",
|
||||
"signature.png",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadSignatureImage() error = %v, want external response passthrough", err)
|
||||
}
|
||||
if string(data) != payload || contentType != "application/json" {
|
||||
t.Fatalf("download = %q (%s), want external response", data, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureCIDsNilSig(t *testing.T) {
|
||||
if cids := signatureCIDs(nil); cids != nil {
|
||||
t.Fatalf("expected nil slice for nil sig, got %v", cids)
|
||||
@@ -251,12 +206,6 @@ func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[st
|
||||
})
|
||||
}
|
||||
|
||||
type signatureRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f signatureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
|
||||
rt, reg := newSigTestRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -145,8 +145,13 @@ var MinutesDownload = common.Shortcut{
|
||||
seen := make(map[string]int)
|
||||
usedNames := make(map[string]bool)
|
||||
|
||||
// Clone the external client so timeout changes stay local.
|
||||
baseClient, err := runtime.Factory.ExternalHTTPClient()
|
||||
// Clone the factory client for download use. We clone the struct (not the
|
||||
// pointer) to avoid mutating the shared singleton's Timeout. The original
|
||||
// transport chain is preserved so security headers and test mocks still work.
|
||||
// SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect
|
||||
// (redirect-level). Transport-level IP check is intentionally omitted because
|
||||
// download URLs originate from the trusted Lark API, not user input.
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -22,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -32,12 +30,6 @@ import (
|
||||
|
||||
var warmOnce sync.Once
|
||||
|
||||
type minutesRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f minutesRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func warmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
warmOnce.Do(func() {
|
||||
@@ -224,52 +216,6 @@ func TestDownload_ServerFilenameTraversalStaysInOutputDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadUsesExternalRequestClass(t *testing.T) {
|
||||
chdir(t, t.TempDir())
|
||||
|
||||
const downloadURL = "https://open.feishu.cn/presigned/download"
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(mediaStub("tok001", downloadURL))
|
||||
|
||||
platform := minutesRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
external := minutesRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
payload := []byte("media")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"video/mp4"}},
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
ContentLength: int64(len(payload)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
|
||||
}, nil
|
||||
}
|
||||
|
||||
err := mountAndRun(t, MinutesDownload, []string{
|
||||
"+download", "--minute-tokens", "tok001", "--output", "out.mp4", "--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MinutesDownload error = %v, want external route", err)
|
||||
}
|
||||
data, err := os.ReadFile("out.mp4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != "media" {
|
||||
t.Fatalf("downloaded content = %q, want external payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilenameFromResponse_EmptyDispositionFilename(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{
|
||||
@@ -796,7 +742,7 @@ func TestDownload_TypedErr_NetworkTransport_HttpError(t *testing.T) {
|
||||
Command: "+probe-dl",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
client, err := rctx.Factory.ExternalHTTPClient()
|
||||
client, err := rctx.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -899,7 +845,7 @@ func TestDownload_TypedErr_OverwriteProtection(t *testing.T) {
|
||||
Command: "+probe-overwrite",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
client, err := rctx.Factory.ExternalHTTPClient()
|
||||
client, err := rctx.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
358
shortcuts/sheets/batch_key_vocab_test.go
Normal file
358
shortcuts/sheets/batch_key_vocab_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
|
||||
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"shortcut": shortcut, "input": input}
|
||||
}
|
||||
|
||||
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
|
||||
// off-vocabulary sub-op input key must error with a did-you-mean instead of
|
||||
// being silently ignored (silent ignore surfaced as misleading "missing
|
||||
// required flag" errors — the top batch error cluster in eval traces).
|
||||
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"rangee": "A1:B2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}), testToken, 0)
|
||||
ve := requireValidation(t, err, `unknown input key "rangee"`)
|
||||
if !strings.Contains(ve.Message, `did you mean "range"`) {
|
||||
t.Fatalf("message %q missing did-you-mean", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "input keys:") {
|
||||
t.Fatalf("hint %q missing key contract", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"dry_run": true,
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, `unknown input key "dry_run"`)
|
||||
})
|
||||
|
||||
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"spreadsheet-token": "shtXXX",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "do not pass input.spreadsheet-token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
|
||||
// the declared flag, and the commandFlagAliases table (size → width/height on
|
||||
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
|
||||
// spelling, the single largest sub-op error cluster).
|
||||
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("camelCase sheetName resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A:C",
|
||||
"type": "pixel",
|
||||
"size": float64(120),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
width, _ := input["resize_width"].(map[string]interface{})
|
||||
if width["value"] != 120 {
|
||||
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "1:3",
|
||||
"type": "pixel",
|
||||
"size": float64(36),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" {
|
||||
t.Fatalf("range = %v, want A1:B2", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2", "C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "split them into 2 sub-ops")
|
||||
})
|
||||
|
||||
// A variant next to its canonical key must reject, not silently overwrite:
|
||||
// keys iterate in sorted order, so the variant's rewrite would land after
|
||||
// the canonical value was already accepted and clobber it.
|
||||
t.Run("camelCase variant alongside canonical rejects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "shadow",
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "got both")
|
||||
})
|
||||
|
||||
t.Run("ranges alongside range rejects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"ranges": []interface{}{"C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "got both")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
|
||||
// several invalid ops come back in a single error (each with its own
|
||||
// operations[i] context) instead of the first only — eval traces show
|
||||
// fix-one-resend loops of up to 7 round trips under first-error-only.
|
||||
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops both reported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
|
||||
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "--cells is required")
|
||||
if strings.Contains(ve.Message, "failed validation") {
|
||||
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
|
||||
// front-runs the server's mid-batch "does not match range" failures.
|
||||
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantContains string // "" = expect success
|
||||
}{
|
||||
{
|
||||
"empty cells prescribes +cells-clear",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
|
||||
"+cells-clear",
|
||||
},
|
||||
{
|
||||
"row count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 1 rows but --range \"A1:B3\" spans 3 rows",
|
||||
},
|
||||
{
|
||||
"column count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"has 1 columns but --range \"A1:B1\" spans 2 columns",
|
||||
},
|
||||
{
|
||||
"matching matrix passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 2 columns but --range \"A1\" spans 1 columns",
|
||||
},
|
||||
{
|
||||
"single-cell range with a single cell passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
|
||||
if tc.wantContains == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
|
||||
// prescription appended to server-side "N succeeded, M failed" errors.
|
||||
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrap := func(inner string) string {
|
||||
return `{"error":` + jsonQuote(inner) + `}`
|
||||
}
|
||||
|
||||
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`), false, true)
|
||||
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("msg %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`), false, true)
|
||||
if !strings.Contains(msg, "resend only the failed operations") {
|
||||
t.Fatalf("msg %q missing failed-only prescription", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero succeeded gets no note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`), false, true)
|
||||
if strings.Contains(msg, "no rollback") {
|
||||
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
|
||||
// the server double-encodes the inner error payload.
|
||||
func jsonQuote(s string) string {
|
||||
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
// TestBatchOp_SpellingConflictRejected pins the uniqueness half of key
|
||||
// canonicalization: two accepted spellings of the same logical flag must not
|
||||
// both survive into the tool body. The flag view resolves hyphen↔underscore
|
||||
// variants, so a leftover duplicate is silently shadowed — with a sheet
|
||||
// selector that means the write lands on whichever spelling won, and the other
|
||||
// value disappears without a word.
|
||||
func TestBatchOp_SpellingConflictRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conflicting values reject", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-id": "first",
|
||||
"sheet_id": "second",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "conflicting values")
|
||||
})
|
||||
|
||||
t.Run("identical values under two spellings pass and collapse to one key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-id": "same",
|
||||
"sheet_id": "same",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_id"] != "same" {
|
||||
t.Fatalf("sheet_id = %v, want same", input["sheet_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hyphen spelling alone is normalized to the underscore form", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-name": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1 (hyphen spelling should canonicalize)", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,15 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
|
||||
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
|
||||
},
|
||||
{
|
||||
// The both-axes form has to hold inside a batch too: it is the only
|
||||
// way to freeze rows AND columns there, since +styles-put (the other
|
||||
// carrier of a combined freeze) is not a batchable sub-op.
|
||||
shortcut: "+dim-freeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--sheet-id", "sh1", "--rows", "1", "--cols", "2"},
|
||||
subInput: `{"sheet-id":"sh1","rows":1,"cols":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-group",
|
||||
sc: DimGroup,
|
||||
@@ -763,7 +772,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --writes plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range+cells each.
|
||||
if fv.Changed("writes") {
|
||||
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own batch request); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
|
||||
}
|
||||
return cellsSetInput(fv, token, sid, sname)
|
||||
}},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --ranges plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range each.
|
||||
if fv.Changed("ranges") {
|
||||
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own batch request); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
|
||||
}
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
@@ -301,6 +316,198 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
|
||||
// appear at a sub-op input's top level — their presence means the caller
|
||||
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
|
||||
// where the flattened flag keys belong. None of the batch sub-op translators
|
||||
// read input under these names, so rejecting them is safe.
|
||||
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
|
||||
|
||||
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
|
||||
// input may carry for `sc`: every non-system flag in flag-defs except the
|
||||
// spreadsheet locators (reserved for the batch top level). Nil when the
|
||||
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
|
||||
func subOpKeyVocabulary(sc string) map[string]bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
vocab := make(map[string]bool, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
vocab[df.Name] = true
|
||||
}
|
||||
return vocab
|
||||
}
|
||||
|
||||
// camelToKebab converts a lowerCamelCase key to its kebab form
|
||||
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
|
||||
// letter (nothing to convert).
|
||||
func camelToKebab(key string) string {
|
||||
if strings.ToLower(key) == key {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range key {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeSubOpInputKeys validates every sub-op input key against the
|
||||
// shortcut's flag vocabulary, rewriting habitual spellings in place and
|
||||
// rejecting anything that matches nothing. Eval traces show unknown keys were
|
||||
// previously ignored silently, which turned "wrong key" (size for width,
|
||||
// camelCase sheetName, an invented styles object) into misleading
|
||||
// "missing required flag" errors downstream — the single largest batch error
|
||||
// cluster. Rewrites applied, in order:
|
||||
//
|
||||
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
|
||||
// mapFlagView — accepted here as-is)
|
||||
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
|
||||
// - the command's intuitive-alias table (size → width/height on the resize
|
||||
// pair) — the same commandFlagAliases the cobra path applies
|
||||
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
|
||||
// array gets a split-into-sub-ops prescription instead
|
||||
//
|
||||
// Anything else errors with a did-you-mean. Returns a bare error; the caller
|
||||
// wraps it with the operations[i] (<shortcut>) context and key contract.
|
||||
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
|
||||
vocab := subOpKeyVocabulary(sc)
|
||||
if vocab == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(input))
|
||||
for k := range input {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
aliases := commandFlagAliases[sc]
|
||||
// canonical tracks which raw key already claimed each logical key, so two
|
||||
// spellings of the same flag (sheet-id / sheet_id / sheetId) can never both
|
||||
// survive into the tool body — the flag view resolves hyphen↔underscore
|
||||
// variants, so a leftover duplicate would be silently shadowed and could
|
||||
// send the write to the wrong sheet.
|
||||
canonical := map[string]string{}
|
||||
claim := func(logical, raw string) error {
|
||||
if prev, taken := canonical[logical]; taken {
|
||||
if jsonEqual(input[prev], input[raw]) {
|
||||
return nil // same value under two spellings: harmless
|
||||
}
|
||||
return fmt.Errorf("%s got conflicting values for %q under two spellings (%q and %q) — keep one", sc, strings.ReplaceAll(logical, "-", "_"), prev, raw) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
canonical[logical] = raw
|
||||
return nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
hv := strings.ReplaceAll(k, "_", "-")
|
||||
if vocab[hv] {
|
||||
if err := claim(hv, k); err != nil {
|
||||
return err
|
||||
}
|
||||
// Normalize the surviving spelling to the underscore form the tool
|
||||
// bodies use, so exactly one key reaches the flag view.
|
||||
if target := strings.ReplaceAll(hv, "-", "_"); target != k {
|
||||
if _, taken := input[target]; !taken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
canonical[hv] = target
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
|
||||
if err := claim(kebab, k); err != nil {
|
||||
return err
|
||||
}
|
||||
target := strings.ReplaceAll(kebab, "-", "_")
|
||||
if _, taken := input[target]; taken {
|
||||
return fmt.Errorf("%s got both %q and %q — keep %q and drop the other", sc, k, target, target) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if _, taken := input[kebab]; taken && kebab != target {
|
||||
return fmt.Errorf("%s got both %q and %q — keep %q and drop the other", sc, k, kebab, kebab) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
canonical[kebab] = target
|
||||
continue
|
||||
}
|
||||
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
|
||||
if err := claim(target, k); err != nil {
|
||||
return err
|
||||
}
|
||||
underscored := strings.ReplaceAll(target, "-", "_")
|
||||
_, hyphenTaken := input[target]
|
||||
_, underscoreTaken := input[underscored]
|
||||
if !hyphenTaken && !underscoreTaken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
// The alias AND its target are both present. This key is recognized,
|
||||
// so it must not fall through to the generic "unknown input key"
|
||||
// below — the claim() conflict message never fires here either,
|
||||
// because keys are walked in sorted order and the alias can sort
|
||||
// before its target ("size" < "width"), so nothing has claimed the
|
||||
// logical key yet. Name both spellings and the survivor.
|
||||
taken := target
|
||||
if underscoreTaken {
|
||||
taken = underscored
|
||||
}
|
||||
if jsonEqual(input[k], input[taken]) {
|
||||
delete(input, k) // same value under two names: drop the alias.
|
||||
// Hand the logical key over to the surviving spelling, or the
|
||||
// claim recorded above would still point at the deleted alias
|
||||
// and make that spelling's own turn read as a conflict.
|
||||
canonical[target] = taken
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s got both %q and %q, which are two names for the same flag, with different values — keep %q", sc, k, taken, taken) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
|
||||
if _, taken := input["range"]; taken {
|
||||
return fmt.Errorf("%s got both %q and \"range\" — keep \"range\" and drop %q", sc, k, k) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if arr, isArr := input[k].([]interface{}); isArr {
|
||||
if len(arr) == 1 {
|
||||
if s, isStr := arr[0].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if s, isStr := input[k].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("unknown input key %q", k)
|
||||
display := make([]string, 0, len(vocab))
|
||||
for name := range vocab {
|
||||
display = append(display, strings.ReplaceAll(name, "-", "_"))
|
||||
}
|
||||
sort.Strings(display)
|
||||
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
@@ -312,6 +519,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - input 顶层出现 cell_styles / cell_merges / styles(误贴 MCP body 包裹结构)
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
@@ -335,7 +543,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
@@ -358,11 +566,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
// 连字符 / 下划线两种写法都算命中(spreadsheet-token 与 spreadsheet_token 同罪)。
|
||||
for userKey := range input {
|
||||
normalized := strings.ReplaceAll(userKey, "-", "_")
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if normalized == k {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, userKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
|
||||
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
|
||||
// the op input, but the op input is the shortcut's own flags flattened into
|
||||
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
|
||||
// an unrelated "at least one style flag is required" (helpers.go), which never
|
||||
// points at the real mistake.
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
@@ -373,6 +600,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
|
||||
// unknown key silently ignored surfaces later as a misleading
|
||||
// "missing required flag" error (the top batch error cluster in evals).
|
||||
if err := normalizeSubOpInputKeys(sc, input); err != nil {
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
// operations is skipped by parse-time schema validation, so type-check the
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
@@ -410,7 +647,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
|
||||
// on one aggregated --operations error, mirroring the schema validator's
|
||||
// display cap.
|
||||
const batchOpErrorDisplayLimit = 5
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
|
||||
// 一次性返回(不再 fail-fast)——agent 一轮就能修完所有坏 op,而不是
|
||||
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
@@ -422,10 +666,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
var opErrs []error
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
opErrs = append(opErrs, err)
|
||||
continue
|
||||
}
|
||||
if len(opErrs) > 0 {
|
||||
continue // already failing — keep scanning for more bad ops, skip cell math.
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
@@ -435,7 +684,31 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
switch len(opErrs) {
|
||||
case 0:
|
||||
return out, nil
|
||||
case 1:
|
||||
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
|
||||
}
|
||||
shown := opErrs
|
||||
truncated := false
|
||||
if len(shown) > batchOpErrorDisplayLimit {
|
||||
shown = shown[:batchOpErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
// aggregatedIssueText keeps each op's own hint (the "<shortcut> input
|
||||
// keys: …" contract) inline: folding N errors leaves one Hint slot, so
|
||||
// without this the multi-op error would carry LESS guidance than the
|
||||
// single-op one it replaces.
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, aggregatedIssueText(e)))
|
||||
}
|
||||
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
|
||||
}
|
||||
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
|
||||
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
|
||||
// regions fan into ONE atomic batch_update, each item self-carrying its
|
||||
// sheet selector (no top-level fallback — same convention as +batch-update
|
||||
// sub-ops and +styles-put items), with per-item errors aggregated.
|
||||
func TestCellsSetWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writes := func(items string, extra ...string) (string, string, error) {
|
||||
args := append([]string{
|
||||
"--url", testURL, "--dry-run", "--writes", items,
|
||||
}, extra...)
|
||||
return runShortcutCapturingErr(t, CellsSet, args)
|
||||
}
|
||||
|
||||
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item without sheet selector errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
requireValidation(t, err, "sheet-id or --sheet-name")
|
||||
})
|
||||
|
||||
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--sheet-name", "S1")
|
||||
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
|
||||
})
|
||||
|
||||
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--range", "A1")
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("per-item errors aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Both items pass the --writes schema (range+cells present) but fail
|
||||
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
|
||||
_, _, err := writes(`[
|
||||
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
|
||||
{"range":"C1","cells":[[{"value":"y"}]]}
|
||||
]`)
|
||||
ve := requireValidation(t, err, "--writes has 2 issues")
|
||||
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("camelCase sheetName must normalize: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "S1") {
|
||||
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot nest inside batch-update", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"writes": []interface{}{map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
|
||||
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
|
||||
// bare shortcut var — mirror the real CLI wiring.
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL, "--dry-run",
|
||||
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--styles", `{"styles":[]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "unknown flag")
|
||||
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
|
||||
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
149
shortcuts/sheets/chart_examples.go
Normal file
149
shortcuts/sheets/chart_examples.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─── +chart-create --print-example ─────────────────────────────────────
|
||||
//
|
||||
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
|
||||
// traces show agents paging through the full --print-schema dump for every
|
||||
// chart (25 round trips in one 35-task batch) and still missing deep
|
||||
// required fields. A ready-to-edit minimal template per chart type answers
|
||||
// the actual question ("what does a valid payload look like") in one local
|
||||
// call. Wired through PostMount, same pattern as +csv-put's flag-group
|
||||
// tweaks — no framework change.
|
||||
//
|
||||
// Templates mirror the canonical examples in the lark-sheets-chart
|
||||
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
|
||||
// inline headerMode with refs covering the header row, 1-based indices,
|
||||
// quoted sheet prefix in refs.
|
||||
|
||||
var chartExampleTemplates = map[string]string{
|
||||
"column": chartSimpleExample("column"),
|
||||
"bar": chartSimpleExample("bar"),
|
||||
"line": chartSimpleExample("line"),
|
||||
"area": chartSimpleExample("area"),
|
||||
"radar": chartSimpleExample("radar"),
|
||||
"scatter": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": "scatter"}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B20"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"pie": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 450},
|
||||
"snapshot": {
|
||||
"title": {"text": "占比标题"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "pie",
|
||||
"series": [{
|
||||
"index": 1,
|
||||
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
|
||||
}]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B11"}],
|
||||
"dim1": {"serie": {"index": 1, "aggregate": true}},
|
||||
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"combo": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 700, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "柱线组合"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "combo",
|
||||
"series": [
|
||||
{"index": 2, "comboType": "column"},
|
||||
{"index": 3, "comboType": "line"}
|
||||
]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C13"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
// chartSimpleExample renders the shared minimal shape for plot types that
|
||||
// need nothing beyond plot.type (column / bar / line / area / radar).
|
||||
func chartSimpleExample(typ string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": %q}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C10"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`, typ)
|
||||
}
|
||||
|
||||
func chartExampleTypes() []string {
|
||||
types := make([]string, 0, len(chartExampleTemplates))
|
||||
for t := range chartExampleTemplates {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// withChartPrintExample wraps +chart-create's PostMount so --print-example
|
||||
// short-circuits execution and prints a minimal ready-to-edit --properties
|
||||
// template — purely local, no identity or network. The flag itself is
|
||||
// declared in flag-defs.json like every other own flag (so it shows up in the
|
||||
// generated reference tables); only the interception lives here.
|
||||
// --properties' cobra-level required annotation is relaxed (the input builder
|
||||
// still enforces it on the real path, same trick as +csv-put's --csv).
|
||||
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
// Only --properties carries a cobra-level required annotation (the
|
||||
// locator flags are xor pairs, enforced later); the input builder
|
||||
// still errors "--properties is required" on the real path.
|
||||
if fl := cmd.Flags().Lookup("properties"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
prevRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
typ, _ := c.Flags().GetString("print-example")
|
||||
if typ == "" {
|
||||
return prevRunE(c, args)
|
||||
}
|
||||
tmpl, ok := chartExampleTemplates[typ]
|
||||
if !ok {
|
||||
return common.ValidationErrorf("no example for chart type %q; available: %s",
|
||||
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
|
||||
}
|
||||
fmt.Fprintln(c.OutOrStdout(), tmpl)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
109
shortcuts/sheets/chart_examples_test.go
Normal file
109
shortcuts/sheets/chart_examples_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChartPrintExample pins the --print-example contract: a known type
|
||||
// prints its template and skips execution entirely; an unknown type lists
|
||||
// the available ones.
|
||||
func TestChartPrintExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("prints template without locator flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
parent, _, _, _ := newTestRig(t, sc)
|
||||
var buf bytes.Buffer
|
||||
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
|
||||
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("print-example should run standalone, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"sectors"`) {
|
||||
t.Errorf("pie template should carry sectors, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type lists available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
|
||||
ve := requireValidation(t, err, `no example for chart type "donut"`)
|
||||
if !strings.Contains(ve.Message, "pie") {
|
||||
t.Errorf("message should list available types, got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--print-example" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--print-example")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
|
||||
// template against the embedded chart-create properties schema — a template
|
||||
// the CLI itself would reject is worse than none.
|
||||
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
for typ, tmpl := range chartExampleTemplates {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
|
||||
t.Fatalf("template is not valid JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
|
||||
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
|
||||
t.Errorf("template rejected by embedded schema: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors_Arrays pins color normalization inside arrays:
|
||||
// the chart schema uses colorTheme / colorScale / highlight_colors, whose
|
||||
// values are LISTS of bare hex strings. Recursing without the key context
|
||||
// dropped the "#" prefix and the server rejected a payload its own schema
|
||||
// allows.
|
||||
func TestNormalizeChartHexColors_Arrays(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"colorTheme": []interface{}{"4472C4", "ED7D31"},
|
||||
"highlight_colors": []interface{}{"FF0000"},
|
||||
"colorScale": []interface{}{map[string]interface{}{"color": "70AD47"}},
|
||||
"backgroundColor": "4472C4",
|
||||
"colorMode": "auto",
|
||||
"title": []interface{}{"4472C4"},
|
||||
}
|
||||
raw, err := json.Marshal(normalizeChartHexColors(in))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
theme := got["colorTheme"].([]interface{})
|
||||
if theme[0] != "#4472C4" || theme[1] != "#ED7D31" {
|
||||
t.Errorf("colorTheme = %v, want both prefixed", theme)
|
||||
}
|
||||
if got["highlight_colors"].([]interface{})[0] != "#FF0000" {
|
||||
t.Errorf("highlight_colors = %v", got["highlight_colors"])
|
||||
}
|
||||
if got["colorScale"].([]interface{})[0].(map[string]interface{})["color"] != "#70AD47" {
|
||||
t.Errorf("colorScale = %v", got["colorScale"])
|
||||
}
|
||||
// Non-hex values under a color-ish key, and hex-looking values under a
|
||||
// non-color key, must both be left alone.
|
||||
if got["colorMode"] != "auto" {
|
||||
t.Errorf("colorMode = %v, want untouched", got["colorMode"])
|
||||
}
|
||||
if got["title"].([]interface{})[0] != "4472C4" {
|
||||
t.Errorf("title = %v, want untouched (not a color key)", got["title"])
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
|
||||
// only when it names a real file (a forgotten @), while leaving genuine inline
|
||||
// content alone — including the case the old name-shape heuristic got wrong:
|
||||
// prose that merely ends in or mentions a filename.
|
||||
// TestGuardCSVValueIsNotFilePath covers the existing-file tier: a bare --csv
|
||||
// value naming a real file is a forgotten "@". The prescription names the fix
|
||||
// with a <path> placeholder — the untrusted value must not be spliced into
|
||||
// command-shaped text an agent would copy verbatim.
|
||||
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
@@ -33,23 +33,98 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
ve := requireValidation(t, err, "existing file")
|
||||
if !strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
|
||||
if !strings.Contains(ve.Message, `"data.csv"`) {
|
||||
t.Errorf("message should name the offending value as data, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "--csv @<path>") {
|
||||
t.Errorf("message should prescribe the @ form via placeholder, got: %q", ve.Message)
|
||||
}
|
||||
if strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message must not splice the value into a command fragment, got: %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--csv" {
|
||||
t.Errorf("param = %q, want --csv", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_MissingButPathShaped covers the second tier.
|
||||
// A path that doesn't resolve used to pass through and be written into the
|
||||
// cell verbatim — a wrong value with a success exit code. The common source is
|
||||
// an absolute path: `@` rejects those, so the caller drops the `@` and retries.
|
||||
// Since the file can't be read from cwd, the prescription is stdin.
|
||||
func TestGuardCSVValueIsNotFilePath_MissingButPathShaped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// Content that is not a real file must pass through unchanged.
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // prose ending in a filename — not a real file
|
||||
"remember to update data.csv", // mentions the real file but isn't its name
|
||||
"nope.csv", // relative path from another working directory
|
||||
"./missing.csv", // explicit relative prefix
|
||||
"../sibling/x.tsv", // parent-relative
|
||||
"/tmp/nope.csv", // absolute — the `@`-rejected case
|
||||
"~/data.tsv", // home-relative
|
||||
"/var/tmp/export", // no extension, but an unmistakable path prefix
|
||||
"C:/Users/me/a.csv", // windows-style, still ASCII path shape
|
||||
} {
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v))
|
||||
ve := requireValidation(t, err, "looks like a file path")
|
||||
if !strings.Contains(ve.Hint, "--csv @") || !strings.Contains(ve.Hint, "--csv - <") {
|
||||
t.Errorf("value %q: hint should offer both @file and stdin, got: %q", v, ve.Hint)
|
||||
}
|
||||
// The untrusted value must never appear inside the command-shaped
|
||||
// hint: "--csv - < $(id).csv" copied by an agent would expand in a
|
||||
// POSIX shell. The value is only named as quoted data in the message.
|
||||
if strings.Contains(ve.Hint, v) {
|
||||
t.Errorf("value %q: hint must not splice the raw value into a command fragment, got: %q", v, ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Message, v) {
|
||||
t.Errorf("value %q: message should still name the offending value, got: %q", v, ve.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_SkipsResolvedInput pins the origin rule that
|
||||
// makes the shape heuristic safe: a value that arrived via @file / stdin is
|
||||
// never inspected, however path-shaped its content — so the hint's promise
|
||||
// that stdin writes such text verbatim actually holds, and a correct
|
||||
// `--csv @file` invocation can't be re-rejected for its content.
|
||||
func TestGuardCSVValueIsNotFilePath_SkipsResolvedInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, v := range []string{
|
||||
"nope.csv", // path-shaped, missing — rejected when inline
|
||||
"data.csv", // names an existing file — rejected when inline
|
||||
} {
|
||||
rctx := newCSVGuardRuntime(v)
|
||||
common.TestMarkInputResolved(rctx, "csv")
|
||||
if err := guardCSVValueIsNotFilePath(rctx); err != nil {
|
||||
t.Errorf("resolved value %q must skip the guard, got: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_PassesThrough pins what must still reach the
|
||||
// sheet untouched. The prose cases are why the guard checks a narrow shape
|
||||
// instead of "contains a filename": an earlier name-shape heuristic rejected
|
||||
// them. "N/A" and "README.md" pin the two narrowing rules — a slash alone is
|
||||
// not a path, and a filename alone is not a CSV path.
|
||||
func TestGuardCSVValueIsNotFilePath_PassesThrough(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // CJK prose ending in a filename
|
||||
"remember to update data.csv", // prose mentioning a file
|
||||
"a,b\n1,2", // multi-cell CSV
|
||||
"hello world",
|
||||
"nope.csv", // path-shaped but no such file
|
||||
"N/A", // slash, but no CSV extension and no path prefix
|
||||
"README.md", // filename shape, not a CSV one
|
||||
"report 2026.csv", // has a space: content, not a path
|
||||
"",
|
||||
} {
|
||||
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@
|
||||
"+float-image-update",
|
||||
"+float-image-delete"
|
||||
],
|
||||
"description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁。"
|
||||
"description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁——美化收尾请单独调 +styles-put,不要拆成子操作数组。"
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
@@ -648,6 +648,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"writes": {
|
||||
"type": "array",
|
||||
"description": "多区域写入项数组(最多 100 项),整批单次批量提交(fail-fast、不回滚);支持跨 sheet。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"range",
|
||||
"cells"
|
||||
],
|
||||
"properties": {
|
||||
"sheet_id": {
|
||||
"type": "string",
|
||||
"description": "目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
|
||||
},
|
||||
"sheet_name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)。"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cells-set-style": {
|
||||
@@ -7748,87 +7777,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+styles-put": {
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
@@ -7856,12 +7805,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8055,7 +8008,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8073,19 +8026,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8104,8 +8070,395 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -8228,12 +8581,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8427,7 +8784,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8445,19 +8802,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8476,8 +8846,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
@@ -50,7 +50,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style): `{ top|bottom|left|right|all: {style,weight,color} }`; style = solid|dashed|dotted|double|none, weight = thin|medium|thick (string), color = hex like #000000", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -59,8 +59,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
|
||||
@@ -72,11 +72,12 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -86,8 +87,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -98,8 +99,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
|
||||
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
|
||||
@@ -115,8 +116,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
@@ -133,10 +134,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE batched request (fail-fast, no rollback), cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
@@ -148,8 +150,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
|
||||
@@ -161,8 +163,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
@@ -175,7 +177,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,weight,color}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides. style = line type (solid|dashed|dotted|double|none); weight = thickness (thin|medium|thick — a string, not a pixel number); color = hex like #000000. { all: {...} } sets all four sides at once. This is the only border flag: no --border-all / --border-top / --border-color exist", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -184,8 +186,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -204,9 +206,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "print-example", Kind: "own", Type: "string", Required: "optional", Desc: "Print a minimal ready-to-edit --properties template for a chart type (area|bar|column|combo|line|pie|radar|scatter) and exit. Purely local: no locator flags, no network; an unknown type lists the available ones"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
@@ -215,8 +218,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -227,8 +230,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -238,8 +241,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -250,10 +253,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one batched call (fail-fast, no rollback). Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -264,8 +267,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
@@ -277,8 +280,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -289,8 +292,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -300,8 +303,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
@@ -314,10 +317,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Note the file is the data payload as JSON — on +csv-get too, where the CSV text sits in a field inside it — not a ready-to-use .csv; redirect stdout instead if you want a bare CSV file. Omit it to print to stdout as usual."},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
@@ -328,8 +332,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
@@ -342,9 +346,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one batched delete (fail-fast, no rollback) — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -354,10 +359,12 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "optional", Desc: "[legacy] Dimension (row or column), paired with --count; sets one axis only and unfreezes the other. Prefer --rows / --cols", Hidden: true, Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "[legacy] Freeze the first N rows/columns (paired with --dimension); 0 clears all freezing. Equivalent to --rows N / --cols N, and only --rows/--cols can hold both axes at once", Hidden: true},
|
||||
{Name: "rows", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N rows; together with --cols this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen rows)"},
|
||||
{Name: "cols", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N columns; together with --rows this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen columns)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -366,8 +373,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
|
||||
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
@@ -379,8 +386,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -390,9 +397,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -403,8 +410,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -415,8 +422,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -427,8 +434,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -448,8 +455,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -459,8 +466,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
@@ -489,8 +496,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -501,8 +508,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -512,8 +519,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -522,8 +529,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -534,8 +541,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
||||
@@ -547,8 +554,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -559,8 +566,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -570,8 +577,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
|
||||
@@ -584,8 +591,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"},
|
||||
@@ -605,8 +612,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -617,8 +624,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -628,8 +635,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"},
|
||||
@@ -702,8 +709,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -714,8 +721,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -725,8 +732,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -737,8 +744,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
@@ -751,8 +758,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
|
||||
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
|
||||
@@ -764,8 +771,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
@@ -777,8 +784,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
|
||||
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
|
||||
@@ -798,10 +805,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one batched call (fail-fast, no rollback). Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -812,8 +819,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -837,8 +844,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -848,8 +855,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -858,8 +865,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -868,8 +875,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -880,8 +887,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -892,8 +899,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -903,8 +910,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -914,8 +921,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -924,8 +931,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -934,8 +941,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -945,8 +952,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -957,8 +964,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -968,13 +975,22 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+styles-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one batched request (fail-fast, no rollback: applied sub-operations stay); ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -983,6 +999,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (cap auto-raises to a bounded 20M chars; explicit --max-chars overrides). Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
|
||||
@@ -38,9 +38,134 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
chainFlagAliases(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
|
||||
//
|
||||
// Eval traces show unknown-flag failures cluster on a handful of habitual
|
||||
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
|
||||
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
|
||||
// the enum-normalization contract above: a name whose value semantics are
|
||||
// identical to the real flag is rewritten silently (zero round-trips); a
|
||||
// name whose fix changes the value or moves it into a JSON field gets a
|
||||
// curated prescription on the unknown-flag error instead — never a silent
|
||||
// rewrite.
|
||||
|
||||
// commandFlagAliases maps, per command, habitual flag names onto the flag
|
||||
// actually registered. Only pairs with identical value semantics belong
|
||||
// here: the rewrite is invisible, so it must be safe to apply unread
|
||||
// (+csv-put --file with a path value still trips the file-path guard, which
|
||||
// prescribes @file / stdin).
|
||||
var commandFlagAliases = map[string]map[string]string{
|
||||
"+csv-put": {"file": "csv"},
|
||||
"+sheet-create": {"name": "title"},
|
||||
// The new name is the only name-valued input a rename takes, so the
|
||||
// habitual spellings are unambiguous (unlike +sheet-copy, where a name
|
||||
// could mean the copy's title or the source selector and gets a
|
||||
// prescription instead). 07-28 root-cause report #25: 10/10 wrote
|
||||
// --new-name, 24 occurrences.
|
||||
"+sheet-rename": {"name": "title", "new-name": "title"},
|
||||
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
|
||||
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
|
||||
// here too — the rename is the single largest sub-op error cluster in
|
||||
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
|
||||
"+cols-resize": {"cols": "range", "size": "width"},
|
||||
"+rows-resize": {"rows": "range", "size": "height"},
|
||||
"+range-fill": {"source": "source-range", "target": "target-range"},
|
||||
"+range-copy": {"source": "source-range", "target": "target-range"},
|
||||
"+range-move": {"source": "source-range", "target": "target-range"},
|
||||
}
|
||||
|
||||
// intuitiveFlagHints carries the prescription for habitual names whose fix
|
||||
// is not a 1:1 rename — the value belongs to a different flag or to a field
|
||||
// inside a JSON payload. The hint spells the exact correct form so the
|
||||
// retry needs no --help round trip.
|
||||
var intuitiveFlagHints = map[string]map[string]string{
|
||||
"+sheet-copy": {
|
||||
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
},
|
||||
"+dim-insert": {
|
||||
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
|
||||
},
|
||||
// Must prescribe --rows / --cols, never the retired --dimension/--count
|
||||
// pair (DEPRECATED(phase-2) on dimFreezeLegacyNote): those flags are hidden
|
||||
// from --help, so they do not even appear in the "valid flags" list printed
|
||||
// beside this hint, and using them earns a second note steering back here.
|
||||
"+dim-freeze": {
|
||||
"frozen-rows": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
|
||||
"frozen-cols": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-columns": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-row-count": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
|
||||
"frozen-col-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-column-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
},
|
||||
"+cells-set-style": {
|
||||
"bold": "use --font-weight bold",
|
||||
"italic": "use --font-style italic",
|
||||
"underline": "use --font-line underline",
|
||||
"font-bold": "use --font-weight bold",
|
||||
"bg-color": "use --background-color",
|
||||
// Google Sheets API vocabulary (wrapStrategy).
|
||||
"wrap-strategy": "use --word-wrap (overflow / auto-wrap / word-clip)",
|
||||
// The border family: the only border flag is --border-styles (composite
|
||||
// JSON); color and per-side variants ride inside it.
|
||||
"border-style": `borders take one composite flag: --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right, or "all" for all four)`,
|
||||
"border-color": `border color rides inside --border-styles JSON, e.g. --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-all": `use --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' — the "all" key applies one spec to all four sides`,
|
||||
"border-top": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-bottom": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-left": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"left":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-right": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"right":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
},
|
||||
"+cells-set": {
|
||||
// Predictable prior from +table-put --styles: models will try to
|
||||
// attach range-level styling to a --writes call the same way.
|
||||
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
|
||||
// +workbook-create's untyped-data flag, carried over to the write
|
||||
// command (07-28 root-cause report #9, 63 occurrences; values↔cells
|
||||
// shares no prefix so edit distance never suggests the fix).
|
||||
"values": `cell contents go in --cells as a 2D array of cell objects ('[[{"value":…},…],…]'); --values is +workbook-create's flag for untyped initial data`,
|
||||
},
|
||||
"+table-put": {
|
||||
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
|
||||
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
},
|
||||
}
|
||||
|
||||
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
|
||||
// (on top of any hook a prior PostMount installed, e.g. --token →
|
||||
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
|
||||
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
|
||||
// canonical name), and the command's intuitive-alias table. Either way a
|
||||
// habitual name parses as the real flag with zero round trips. Aliases
|
||||
// never shadow a registered flag and never appear in --help; an alias whose
|
||||
// target vanished (spec-side rename) is dropped, degrading to the
|
||||
// unknown-flag prescription.
|
||||
func chainFlagAliases(cmd *cobra.Command) {
|
||||
aliases := commandFlagAliases[cmd.Name()]
|
||||
usable := make(map[string]string, len(aliases))
|
||||
for alias, target := range aliases {
|
||||
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
|
||||
usable[alias] = target
|
||||
}
|
||||
}
|
||||
prev := cmd.Flags().GetNormalizeFunc()
|
||||
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if strings.Contains(name, "_") {
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
}
|
||||
if target, ok := usable[name]; ok {
|
||||
name = target
|
||||
}
|
||||
return prev(fs, name)
|
||||
})
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
@@ -50,6 +175,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
|
||||
// top-level sheet locator (each sub-op names its own sheet inside its input),
|
||||
// yet agents reach for --sheet-id / --sheet-name at the top level. An
|
||||
// edit-distance suggestion would only mislead here, so skip it and name the
|
||||
// real contract instead. Underscore spellings (--sheet_id) are matched too:
|
||||
// the error message itself teaches the underscore key names, and sub-op
|
||||
// inputs accept them, so agents mix the two styles.
|
||||
locatorName := strings.ReplaceAll(name, "_", "-")
|
||||
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
|
||||
}
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
@@ -67,6 +205,21 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
// A curated prescription beats both: it spells the exact correct form
|
||||
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
|
||||
// Edit-distance candidates are dropped with it — they can contradict the
|
||||
// prescription (--font-bold ranked --font-color/--font-line/--font-size
|
||||
// while the fix is --font-weight), and a machine-readable suggestion that
|
||||
// disagrees with the hint sends agents down the wrong retry.
|
||||
// The map is keyed hyphenated but the parse error reports the flag as
|
||||
// typed, so --frozen_rows must hit the same entry as --frozen-rows.
|
||||
if rx, ok := intuitiveFlagHints[c.Name()][strings.ReplaceAll(name, "_", "-")]; ok {
|
||||
hint = rx
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = rx + "; valid flags: " + list
|
||||
}
|
||||
suggestions = nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
@@ -139,6 +292,69 @@ var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
|
||||
// from the API docs; lowercased by canonicalEnumValue before lookup.
|
||||
"merge_all": "all",
|
||||
"merge_rows": "rows",
|
||||
"merge_columns": "columns",
|
||||
// Boolean-style word-wrap habits: true unambiguously means wrap on;
|
||||
// false means "don't wrap", whose Lark default is overflow (word-clip is
|
||||
// a distinct truncation mode nobody spells "false").
|
||||
"true": "auto-wrap",
|
||||
"false": "overflow",
|
||||
// Google Sheets wrapStrategy vocabulary: WRAP / CLIP / OVERFLOW. Only
|
||||
// the first two need mapping — overflow is spelled the same in both.
|
||||
"wrap": "auto-wrap",
|
||||
"clip": "word-clip",
|
||||
}
|
||||
|
||||
// DEPRECATED(phase-2): enum values this CLI used to accept and now expresses
|
||||
// by omitting the flag. They are dropped from the published enum so the docs
|
||||
// and --help stop teaching them, but a caller that still passes one must not
|
||||
// hard-fail: the value was valid — for --inherit-style it was even the
|
||||
// DEFAULT — so existing scripts and any agent carrying older docs would break
|
||||
// on a spelling that never meant anything else.
|
||||
//
|
||||
// Semantics: a retired value is cleared, making the call identical to omitting
|
||||
// the flag (pinned by TestRetiredEnumValueMatchesOmitted). It is deliberately
|
||||
// silent — unlike --dimension/--count there is nothing for the caller to
|
||||
// migrate to, so a note would only be noise.
|
||||
//
|
||||
// Phase 2 removal: drop the entry here and let the normal enum error apply.
|
||||
var retiredEnumValues = map[string]map[string][]string{
|
||||
// +dim-insert --inherit-style dropped "none" when the side mapping was
|
||||
// corrected: no inheritance is what omitting the flag already means, so
|
||||
// the value was pure redundancy.
|
||||
"+dim-insert": {"inherit-style": {"none"}},
|
||||
}
|
||||
|
||||
// clearRetiredFlag makes a retired value indistinguishable from an absent
|
||||
// flag. Resetting Changed matters as much as the value: the batch path
|
||||
// expresses "as if omitted" by deleting the key, so Changed() reports false
|
||||
// there. Leaving cobra's Changed at true would make a flag whose logic reads
|
||||
// Changed() (rather than the value) behave differently standalone than inside
|
||||
// +batch-update — and TestBatchOp_BodyMatchesStandalone only catches such a
|
||||
// split once it reaches the request body.
|
||||
func clearRetiredFlag(cmd *cobra.Command, name string) {
|
||||
_ = cmd.Flags().Set(name, "")
|
||||
if f := cmd.Flags().Lookup(name); f != nil {
|
||||
f.Changed = false
|
||||
}
|
||||
}
|
||||
|
||||
// isRetiredEnumValue reports whether val is a retired spelling for this
|
||||
// command's flag, i.e. one that should be cleared rather than rejected.
|
||||
func isRetiredEnumValue(command, flag, val string) bool {
|
||||
byFlag, ok := retiredEnumValues[command]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, retired := range byFlag[flag] {
|
||||
if strings.EqualFold(retired, val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
@@ -225,6 +441,10 @@ func chainEnumNormalization(cmd *cobra.Command) {
|
||||
c.Flags().Set(df.Name, canon)
|
||||
continue
|
||||
}
|
||||
if isRetiredEnumValue(cmd.Name(), df.Name, val) {
|
||||
clearRetiredFlag(c, df.Name)
|
||||
continue
|
||||
}
|
||||
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
|
||||
val, df.Name, strings.Join(df.Enum, ", ")).
|
||||
WithParam("--" + df.Name)
|
||||
|
||||
@@ -96,6 +96,54 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
|
||||
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
|
||||
// the per-op locator contract instead of offering a misleading fuzzy guess.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
|
||||
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
|
||||
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
|
||||
}
|
||||
if len(verr.Params[0].Suggestions) != 0 {
|
||||
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
|
||||
// special case is scoped to the two sheet-locator flags: any other unknown
|
||||
// flag on +batch-update keeps the normal did-you-mean behaviour.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if strings.Contains(verr.Message, "no top-level sheet locator") {
|
||||
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
@@ -284,9 +332,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--col-size", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
ve := requireValidation(t, err, `unknown flag "--col-size"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
@@ -294,3 +342,289 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
|
||||
// habitual name with identical value semantics parses as the real flag on a
|
||||
// mounted command, costing zero round trips (eval: --cols, --file, --name,
|
||||
// --source/--target each burned an unknown-flag failure plus a --help call).
|
||||
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--width", "100",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "A:D") {
|
||||
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-create")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--name", "汇总",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "汇总") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-rename --new-name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-rename")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--new-name", "授权需求清单",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--new-name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "授权需求清单") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+range-fill")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--source", "B2",
|
||||
"--target", "B3:B10",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{"B2", "B3:B10"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+csv-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--start-cell", "A1",
|
||||
"--file", "a,b\n1,2",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "a,b") {
|
||||
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A:C",
|
||||
"--size", "120",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "120") {
|
||||
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+rows-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "1:3",
|
||||
"--size", "36",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias never shadows a registered flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+csv-put"}
|
||||
c.Flags().String("csv", "", "")
|
||||
c.Flags().String("file", "", "") // hypothetical real flag wins
|
||||
chainFlagAliases(c)
|
||||
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("file"); got != "x" {
|
||||
t.Errorf("registered --file should keep its own value, got %q", got)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("csv"); got != "" {
|
||||
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
|
||||
// names whose fix is not a rename answer with the exact correct form, so the
|
||||
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
|
||||
// --help calls, +dim-insert kept failing even after reading help).
|
||||
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
command string
|
||||
args []string
|
||||
wrong string
|
||||
wantHint []string
|
||||
// rejectHint pins what a prescription must NOT name — used where the
|
||||
// obvious wording would steer into a deprecated flag.
|
||||
rejectHint []string
|
||||
}{
|
||||
{
|
||||
command: "+dim-insert",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
|
||||
wrong: "--dimension",
|
||||
wantHint: []string{"--position", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
|
||||
wrong: "--frozen-rows",
|
||||
// Must prescribe the CURRENT spelling: --dimension/--count is
|
||||
// retired and hidden from --help, so a hint naming it would point at
|
||||
// a flag missing from the same error's valid-flags list.
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
|
||||
wrong: "--bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+sheet-copy",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
|
||||
wrong: "--new-sheet-name",
|
||||
wantHint: []string{"--title", "source sheet"},
|
||||
},
|
||||
{
|
||||
command: "+table-put",
|
||||
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
|
||||
wrong: "--start-cell",
|
||||
wantHint: []string{`"start_cell"`, "+csv-put"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--values", `[["x"]]`},
|
||||
wrong: "--values",
|
||||
wantHint: []string{"--cells", "+workbook-create"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-row-count", "1"},
|
||||
wrong: "--frozen-row-count",
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
// The parse error reports the flag as typed: the underscore
|
||||
// spelling must hit the same curated entry as the hyphenated one.
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen_rows", "2"},
|
||||
wrong: "--frozen_rows",
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--font-bold", "true"},
|
||||
wrong: "--font-bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bg-color", "#FFF"},
|
||||
wrong: "--bg-color",
|
||||
wantHint: []string{"--background-color"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--wrap-strategy", "overflow"},
|
||||
wrong: "--wrap-strategy",
|
||||
wantHint: []string{"--word-wrap"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-all", "thin"},
|
||||
wrong: "--border-all",
|
||||
wantHint: []string{"--border-styles", `"all"`},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-top", "thin"},
|
||||
wrong: "--border-top",
|
||||
wantHint: []string{"--border-styles", `"top"`},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-color", "#000"},
|
||||
wrong: "--border-color",
|
||||
wantHint: []string{"--border-styles", "color"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, tc.command)
|
||||
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
|
||||
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
|
||||
for _, want := range tc.wantHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
// The valid-flags list is appended to the same Hint, so only the
|
||||
// prescription itself is checked for banned wording.
|
||||
prescription, _, _ := strings.Cut(ve.Hint, "; valid flags:")
|
||||
for _, banned := range tc.rejectHint {
|
||||
if strings.Contains(prescription, banned) {
|
||||
t.Errorf("prescription must not steer to %q, got %q", banned, prescription)
|
||||
}
|
||||
}
|
||||
// A curated prescription must not ship contradicting edit-distance
|
||||
// candidates (--font-bold used to carry --font-color/--font-line/
|
||||
// --font-size in params while the fix is --font-weight).
|
||||
for _, p := range ve.Params {
|
||||
if len(p.Suggestions) > 0 {
|
||||
t.Errorf("curated prescription should drop edit-distance suggestions, got %v", p.Suggestions)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
//
|
||||
// flagName also accepts a dotted path (properties.plotArea.axes): the
|
||||
// first segment names the flag, the rest walk the schema's properties
|
||||
// (descending through array items implicitly), returning just that
|
||||
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
|
||||
// lines — otherwise force agents to page through the full dump for one
|
||||
// nested field; eval traces show 25 such round trips in one batch.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
name, path := splitSchemaPath(flagName)
|
||||
schema, ok := entry[name]
|
||||
if !ok {
|
||||
// Tolerate the wire-vocabulary underscore form (--flag-name
|
||||
// border_styles for border-styles) — agents copy field names out
|
||||
// of JSON payloads where underscores are canonical.
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
schema, ok = entry[alt]
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) > 0 {
|
||||
pretty, err = sliceSchemaByPath(pretty, name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
|
||||
// splitSchemaPath splits a --flag-name value into the flag name and the
|
||||
// optional dotted schema path after it.
|
||||
func splitSchemaPath(flagName string) (string, []string) {
|
||||
parts := strings.Split(flagName, ".")
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
|
||||
// Each segment matches a key under "properties"; array levels are descended
|
||||
// implicitly through "items" (an explicit "items" segment also works), and
|
||||
// oneOf branches are searched for the first one carrying the key. A miss
|
||||
// errors with the keys actually available at that level so the caller can
|
||||
// re-issue the path without a full dump.
|
||||
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
|
||||
node := schema
|
||||
walked := flagName
|
||||
for _, seg := range path {
|
||||
next, ok := schemaChild(node, seg)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
node = next
|
||||
walked += "." + seg
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// schemaChild resolves one path segment against a schema node, descending
|
||||
// through items / oneOf wrappers as needed.
|
||||
func schemaChild(node interface{}, seg string) (interface{}, bool) {
|
||||
for depth := 0; depth < 8; depth++ {
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if seg == "items" {
|
||||
if items, ok := m["items"]; ok {
|
||||
return items, true
|
||||
}
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
if child, ok := props[seg]; ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
node = items
|
||||
continue
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
if child, ok := schemaChild(b, seg); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// schemaChildKeys lists the property keys reachable at a schema node (through
|
||||
// items / oneOf wrappers), for the path-miss error.
|
||||
func schemaChildKeys(node interface{}) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var collect func(n interface{}, depth int)
|
||||
collect = func(n interface{}, depth int) {
|
||||
if depth > 8 {
|
||||
return
|
||||
}
|
||||
m, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
for k := range props {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
collect(items, depth+1)
|
||||
return
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
collect(b, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(node, 0)
|
||||
keys := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -204,3 +204,109 @@ func keysOf(m map[string]interface{}) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPrintSchema_DottedPathSlicing covers --flag-name's dotted-path form,
|
||||
// which had no tests at all: disabling the implicit items/oneOf descent, or the
|
||||
// explicit "items" segment, broke nothing.
|
||||
//
|
||||
// The feature exists so agents can pull one subtree out of chart-create's
|
||||
// ~1,750-line properties schema instead of paging the whole dump (SKILL.md
|
||||
// points at it by name). A silent regression pushes them straight back to full
|
||||
// dumps, which is invisible in any output-correctness test.
|
||||
func TestPrintSchema_DottedPathSlicing(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+chart-create")
|
||||
|
||||
decode := func(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var node map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
t.Fatalf("schema slice is not a JSON object: %v", err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
props := func(t *testing.T, node map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
p, ok := node["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node has no properties: %v", node)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("one segment walks into properties", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := print("properties.snapshot")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
if _, has := props(t, decode(t, raw))["plotArea"]; !has {
|
||||
t.Errorf("snapshot subtree should expose plotArea, got %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array levels are descended implicitly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// data.refs is an array; naming the field must land on the ITEM shape,
|
||||
// not force the caller to spell ".items".
|
||||
raw, err := print("properties.snapshot.data.refs")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
node := decode(t, raw)
|
||||
if node["type"] != "array" {
|
||||
t.Errorf("refs should still be the array node, got %v", node["type"])
|
||||
}
|
||||
deeper, err := print("properties.snapshot.data.refs.value")
|
||||
if err != nil {
|
||||
t.Fatalf("descending through array items failed: %v", err)
|
||||
}
|
||||
if len(deeper) == 0 {
|
||||
t.Error("expected the item's value field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an explicit items segment also works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := print("properties.snapshot.plotArea.axes.items"); err != nil {
|
||||
t.Fatalf("explicit items segment failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a slice is strictly smaller than the whole flag schema", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
full, err := print("properties")
|
||||
if err != nil {
|
||||
t.Fatalf("full dump failed: %v", err)
|
||||
}
|
||||
slice, err := print("properties.snapshot.plotArea.axes")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
if len(slice) >= len(full) {
|
||||
t.Errorf("slice is %d bytes vs %d for the full schema — slicing saves nothing", len(slice), len(full))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a miss names the keys actually available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := print("properties.snapshot.nope")
|
||||
if err == nil {
|
||||
t.Fatal("want an error for an unknown segment")
|
||||
}
|
||||
ve := requireValidation(t, err, `no "nope" under properties.snapshot`)
|
||||
if !strings.Contains(ve.Message, "plotArea") {
|
||||
t.Errorf("the miss must list the reachable keys so the caller can retry without a full dump, got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--flag-name" {
|
||||
t.Errorf("param = %q, want --flag-name", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the underscore spelling of the flag still resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := printFlagSchemaFor("+cells-set-style")("border_styles"); err != nil {
|
||||
t.Fatalf("underscore flag name should resolve to border-styles: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── schema-driven flag validation ────────────────────────────────────
|
||||
@@ -94,7 +96,15 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
}
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, &schema, "", c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
vErr := c.errs[0]
|
||||
if len(c.errs) == 1 {
|
||||
// Single failure keeps the historical message byte-for-byte.
|
||||
//
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
@@ -106,19 +116,69 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// exact JSON Schema for this (command, flag) pair; reaching this
|
||||
// branch means entry[name] resolved a schema from the embedded
|
||||
// index, so the suggested command is guaranteed to print it.
|
||||
// An enum-bearing field states its own contract far better than a
|
||||
// whole-payload skeleton, at any depth: --border-styles with
|
||||
// weight:1 used to answer with {"bottom": {…}, "left": {…}, …},
|
||||
// which says nothing about thin/medium/thick. Let those fall
|
||||
// through to the hintSuffix path below, which names the enum.
|
||||
var tm *typeMismatchError
|
||||
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
isTypeMismatch := errors.As(vErr, &tm)
|
||||
if isTypeMismatch && len(tm.enum) == 0 && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
|
||||
name, vErr.Error(), sk, command, name).WithCause(vErr)
|
||||
}
|
||||
}
|
||||
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
|
||||
// address the actual field), but if the field itself carries an enum /
|
||||
// description, append that one line — same "fix on first retry" goal.
|
||||
msg := vErr.Error()
|
||||
if isTypeMismatch {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name).WithCause(vErr)
|
||||
name, msg, command, name).WithCause(vErr)
|
||||
}
|
||||
return nil
|
||||
// Multiple failures: report them all at once (numbered, each with its
|
||||
// own inline teaching hint) so the agent fixes the whole payload in one
|
||||
// retry instead of the fail-fast "fix one, hit the next" loop.
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
|
||||
}
|
||||
|
||||
// formatSchemaErrorList renders collected failures as a numbered one-line
|
||||
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
|
||||
// their enum/description suffix just like the single-error path. Entries
|
||||
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
|
||||
// collector stops at cap, so the exact total is unknown by design.
|
||||
func formatSchemaErrorList(errs []error) string {
|
||||
shown := errs
|
||||
truncated := false
|
||||
if len(shown) > schemaErrorDisplayLimit {
|
||||
shown = shown[:schemaErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
msg := e.Error()
|
||||
var tm *typeMismatchError
|
||||
if errors.As(e, &tm) {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
|
||||
}
|
||||
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateInputAgainstSchema validates input[flag] for every flag the
|
||||
@@ -187,8 +247,10 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
}
|
||||
|
||||
// schemaProperty mirrors the JSON Schema subset used by
|
||||
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
|
||||
// they're documentation.
|
||||
// data/flag-schemas.json. Description is retained (not just documentation)
|
||||
// so a required-missing or type-mismatch error can inline the one-line
|
||||
// field doc — the agent then fixes the input without a --print-schema round
|
||||
// trip. Other unknown keys stay dropped.
|
||||
//
|
||||
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
||||
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
||||
@@ -204,6 +266,7 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
// map<string, array<string>> fields (groups / collapse).
|
||||
type schemaProperty struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Nullable bool `json:"nullable"`
|
||||
Enum []interface{} `json:"enum"`
|
||||
Properties map[string]*schemaProperty `json:"properties"`
|
||||
@@ -242,20 +305,66 @@ func (a *additionalProps) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaErrorCollector accumulates validation failures during one full
|
||||
// traversal so the caller can report every problem in a single reply
|
||||
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
|
||||
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
|
||||
// 5000-row --cells array where every cell is malformed — cannot balloon
|
||||
// the error message or the traversal cost: once full, collection
|
||||
// short-circuits everywhere via full().
|
||||
type schemaErrorCollector struct {
|
||||
errs []error
|
||||
}
|
||||
|
||||
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
|
||||
// schemaErrorDisplayLimit entries are rendered; one extra is collected
|
||||
// only to know that truncation happened.
|
||||
const (
|
||||
schemaErrorDisplayLimit = 5
|
||||
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
|
||||
)
|
||||
|
||||
func (c *schemaErrorCollector) add(err error) {
|
||||
if len(c.errs) < collectSchemaErrorsCap {
|
||||
c.errs = append(c.errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
|
||||
|
||||
// validateAgainstSchema recursively checks `value` against `schema`,
|
||||
// prefixing any failure with the JSON path navigated so far.
|
||||
// prefixing any failure with the JSON path navigated so far. It reports
|
||||
// only the first failure — callers that want the full list (the
|
||||
// error-as-teaching aggregate path) use collectSchemaErrors directly.
|
||||
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
|
||||
if schema == nil {
|
||||
return nil // defensive — current callers always pass &schema, but
|
||||
// keeps validator safe for future programmatic construction.
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, schema, path, c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.errs[0]
|
||||
}
|
||||
|
||||
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
|
||||
// same checks, same messages, same deterministic order, but it keeps
|
||||
// walking after a failure and appends every problem to the collector
|
||||
// (until cap). Two deliberate exceptions to "keep walking":
|
||||
// - a type mismatch stops descent into that node (its children would
|
||||
// produce cascading nonsense against the wrong-typed value);
|
||||
// - oneOf alternatives are probed with throwaway collectors (a failed
|
||||
// alternative is not an error when a later one matches).
|
||||
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
|
||||
if schema == nil || c.full() {
|
||||
return
|
||||
}
|
||||
if value == nil && schema.Nullable {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
|
||||
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
|
||||
return // wrong container type — descending would cascade nonsense.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,20 +372,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,20 +403,22 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
if len(schema.OneOf) > 0 {
|
||||
matched := false
|
||||
for _, sub := range schema.OneOf {
|
||||
if validateAgainstSchema(value, sub, path) == nil {
|
||||
probe := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, sub, path, probe)
|
||||
if len(probe.errs) == 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +427,18 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// the schema also describes their per-key shape via `properties`.
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
// Inline the missing field's type / one-line description / enum so
|
||||
// the agent supplies a correctly-shaped value on the first retry
|
||||
// instead of fetching the full schema.
|
||||
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
|
||||
msg += "; expected " + hint
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -327,6 +448,9 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
sub := schema.Properties[key]
|
||||
v, present := obj[key]
|
||||
if !present {
|
||||
@@ -350,14 +474,12 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(v, sub, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(v, sub, child, c)
|
||||
}
|
||||
}
|
||||
// additionalProperties: enforce only when explicitly declared.
|
||||
// Absent means lenient (matches the file header's stance). Sort
|
||||
// extras so the first rejection is deterministic across runs.
|
||||
// extras so rejection order is deterministic across runs.
|
||||
if schema.AdditionalProperties != nil {
|
||||
extras := make([]string, 0)
|
||||
for key := range obj {
|
||||
@@ -368,17 +490,29 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
// Inline the node's declared keys (and a did-you-mean when the
|
||||
// unknown key is a near miss) so the agent renames it in one
|
||||
// retry instead of a --print-schema round trip.
|
||||
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
|
||||
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
|
||||
}
|
||||
msg += "; valid properties: " + formatPropertyKeyList(legal)
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
continue
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,33 +521,50 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if schema.Type == "array" && schema.Items != nil {
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil // type mismatch already reported above.
|
||||
return // type mismatch already reported above.
|
||||
}
|
||||
for i, item := range arr {
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
|
||||
return err
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
collectSchemaErrors(item, schema.Items, child, c)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// typeMismatchError is the type-check branch of validateAgainstSchema
|
||||
// as a typed error, so validateValueAgainstSchema can recognize shape
|
||||
// confusion (vs. deep value errors) and inline a skeleton of the
|
||||
// expected shape. Error() keeps the exact legacy wording.
|
||||
// expected shape. Error() keeps the exact legacy wording; enum /
|
||||
// description ride alongside for the deep-mismatch hintSuffix, so they
|
||||
// never leak into the shallow-skeleton message.
|
||||
type typeMismatchError struct {
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
enum []interface{}
|
||||
description string
|
||||
}
|
||||
|
||||
func (e *typeMismatchError) Error() string {
|
||||
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
||||
}
|
||||
|
||||
// hintSuffix renders the field's description / enum as a one-line tail for
|
||||
// the deep type-mismatch fallback (type is already stated by Error()).
|
||||
// Empty when the field declares neither.
|
||||
func (e *typeMismatchError) hintSuffix() string {
|
||||
var parts []string
|
||||
if d := oneLineDescription(e.description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(e.enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(e.enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// pathDepth counts how many levels below the flag root a JSON path
|
||||
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
|
||||
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
|
||||
@@ -605,6 +756,70 @@ func joinFormatted(values []interface{}) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// schemaFieldHint renders a compact one-line "type X, description: …, one of
|
||||
// […]" sketch of a single field's schema, used to enrich a required-missing
|
||||
// error so the agent supplies a correctly-shaped value without --print-schema.
|
||||
// Empty when the field declares none of type / description / enum.
|
||||
func schemaFieldHint(s *schemaProperty) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if s.Type != "" {
|
||||
parts = append(parts, fmt.Sprintf("type %q", s.Type))
|
||||
}
|
||||
if d := oneLineDescription(s.Description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(s.Enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(s.Enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// sortedSchemaPropertyKeys returns the declared property names in a stable
|
||||
// (sorted) order so the valid-property list in a strict unexpected-property
|
||||
// error is deterministic across runs.
|
||||
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
|
||||
keys := make([]string, 0, len(props))
|
||||
for k := range props {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// propertyKeyDisplayLimit caps how many declared property names ride inline on
|
||||
// a strict unexpected-property error, so a wide object doesn't bury the actual
|
||||
// error under a wall of keys. Overflow is summarised as "(N more)".
|
||||
const propertyKeyDisplayLimit = 15
|
||||
|
||||
func formatPropertyKeyList(keys []string) string {
|
||||
if len(keys) <= propertyKeyDisplayLimit {
|
||||
return "[" + strings.Join(keys, ", ") + "]"
|
||||
}
|
||||
shown := keys[:propertyKeyDisplayLimit]
|
||||
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
|
||||
}
|
||||
|
||||
// descriptionMaxLen bounds an inlined field description to one reasonable line;
|
||||
// schema descriptions can run several sentences, which would swamp the error.
|
||||
const descriptionMaxLen = 120
|
||||
|
||||
// oneLineDescription collapses a (possibly multi-line) schema description into
|
||||
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
|
||||
// Returns "" for an empty / whitespace-only description.
|
||||
func oneLineDescription(s string) string {
|
||||
collapsed := strings.Join(strings.Fields(s), " ")
|
||||
if collapsed == "" {
|
||||
return ""
|
||||
}
|
||||
if r := []rune(collapsed); len(r) > descriptionMaxLen {
|
||||
return string(r[:descriptionMaxLen]) + "…"
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
// suggestEnumMatch returns the canonical enum entry when the user's
|
||||
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
|
||||
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -438,6 +440,372 @@ func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys pins the strict
|
||||
// additionalProperties:false enhancement: the error lists the node's legal
|
||||
// property keys (sorted, capped at 15 with an "(N more)" overflow) and, when
|
||||
// the unknown key is a near miss, appends a did-you-mean.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("lists legal keys and suggests a near miss", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{
|
||||
"background_color":{"type":"string"},
|
||||
"font_weight":{"type":"string"},
|
||||
"font_size":{"type":"integer"}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"background_colour": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key under strict schema must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `unexpected property "background_colour"`) {
|
||||
t.Errorf("want the offending key named; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `did you mean "background_color"?`) {
|
||||
t.Errorf("want a did-you-mean for the near miss; got %q", msg)
|
||||
}
|
||||
for _, want := range []string{"valid properties:", "background_color", "font_size", "font_weight"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want valid-property list to contain %q; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no did-you-mean for an unrelated key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"zzzzzzzz": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "did you mean") {
|
||||
t.Errorf("unrelated key should get no suggestion; got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "valid properties: [background_color]") {
|
||||
t.Errorf("want the valid-property list; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wide object truncates the key list with overflow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := make([]string, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
props = append(props, fmt.Sprintf(`"k%02d":{"type":"string"}`, i))
|
||||
}
|
||||
schema := parseSchema(t, `{"type":"object","additionalProperties":false,"properties":{`+strings.Join(props, ",")+`}}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"nope": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(5 more)") { // 20 keys, cap 15
|
||||
t.Errorf("want overflow marker '(5 more)'; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingInlinesFieldHint pins that a
|
||||
// required-property-missing error inlines the field's type / one-line
|
||||
// description / enum when the schema describes that field.
|
||||
func TestValidateAgainstSchema_RequiredMissingInlinesFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"required":["operation"],
|
||||
"properties":{
|
||||
"operation":{
|
||||
"type":"string",
|
||||
"description":"Which mutation to run.",
|
||||
"enum":["insert","delete","move"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required property must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "operation"`,
|
||||
`type "string"`,
|
||||
"description: Which mutation to run.",
|
||||
`one of ["insert", "delete", "move"]`,
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain pins that a
|
||||
// missing required key with no describing schema keeps the plain legacy
|
||||
// message (no trailing "expected ...").
|
||||
func TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"type":"object","required":["a"]}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "; expected") {
|
||||
t.Errorf("no field schema → no inlined hint; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum pins that a deep
|
||||
// type mismatch (past the skeleton depth limit) still gets no whole-shape
|
||||
// skeleton, but appends the field's enum / description one-liner.
|
||||
func TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A wrong-typed value three levels deep where the field is an enum string.
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{"type":"array","items":{"type":"object","properties":{
|
||||
"align":{"type":"string","description":"Text alignment.","enum":["left","center","right"]}
|
||||
}}}
|
||||
}`)
|
||||
deep := parseValue(t, `[[{"align":42}]]`)
|
||||
err := validateAgainstSchema(deep, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("wrong type for align must fail")
|
||||
}
|
||||
var tm *typeMismatchError
|
||||
if !errors.As(err, &tm) {
|
||||
t.Fatalf("want *typeMismatchError, got %T", err)
|
||||
}
|
||||
suffix := tm.hintSuffix()
|
||||
for _, want := range []string{"description: Text alignment.", `one of ["left", "center", "right"]`} {
|
||||
if !strings.Contains(suffix, want) {
|
||||
t.Errorf("want %q in hintSuffix; got %q", want, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaFieldHint covers the single-field sketch used by
|
||||
// required-missing errors: each of type / description / enum contributes
|
||||
// its own segment, absent parts are simply skipped, and a nil / empty
|
||||
// schema yields no hint at all.
|
||||
func TestSchemaFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
schema *schemaProperty
|
||||
want string
|
||||
}{
|
||||
{"nil schema", nil, ""},
|
||||
{"empty schema", &schemaProperty{}, ""},
|
||||
{"type only", &schemaProperty{Type: "string"}, `type "string"`},
|
||||
{"description only", &schemaProperty{Description: "Cell note."}, "description: Cell note."},
|
||||
{"enum only", &schemaProperty{Enum: []interface{}{"a", "b"}}, `one of ["a", "b"]`},
|
||||
{
|
||||
"all three",
|
||||
&schemaProperty{Type: "string", Description: "段类型", Enum: []interface{}{"text", "link"}},
|
||||
`type "string", description: 段类型, one of ["text", "link"]`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := schemaFieldHint(tc.schema); got != tc.want {
|
||||
t.Errorf("schemaFieldHint = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatPropertyKeyList_Boundaries pins the display cap edges: exactly
|
||||
// at the cap nothing is folded, one past the cap folds into "(1 more)".
|
||||
func TestFormatPropertyKeyList_Boundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := make([]string, 0, propertyKeyDisplayLimit+1)
|
||||
for i := 0; i < propertyKeyDisplayLimit; i++ {
|
||||
keys = append(keys, fmt.Sprintf("k%02d", i))
|
||||
}
|
||||
if got := formatPropertyKeyList(keys); strings.Contains(got, "more)") {
|
||||
t.Errorf("exactly %d keys must not fold, got %q", propertyKeyDisplayLimit, got)
|
||||
}
|
||||
keys = append(keys, "overflow")
|
||||
if got := formatPropertyKeyList(keys); !strings.Contains(got, "(1 more)") {
|
||||
t.Errorf("%d keys should fold into '(1 more)', got %q", propertyKeyDisplayLimit+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeMismatchHintSuffix_EmptyWhenUndeclared pins that a field with
|
||||
// neither enum nor description adds no suffix — the deep-mismatch fallback
|
||||
// message must stay byte-identical to the legacy wording in that case.
|
||||
func TestTypeMismatchHintSuffix_EmptyWhenUndeclared(t *testing.T) {
|
||||
t.Parallel()
|
||||
tm := &typeMismatchError{path: "a.b", expected: "string", got: "number"}
|
||||
if got := tm.hintSuffix(); got != "" {
|
||||
t.Errorf("no enum/description → empty suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo pins the
|
||||
// did-you-mean for a key that differs from a legal one only in casing /
|
||||
// underscore style — a high-frequency LLM slip.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"Background_Color": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("case-typo key under strict schema must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "background_color"?`) {
|
||||
t.Errorf("want case-insensitive did-you-mean; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_RequiredMissingRealSchema replays 场景3
|
||||
// of the doubao case against the real embedded flag-schemas.json: a
|
||||
// rich_text segment without "type" must inline the field's type, enum and
|
||||
// description while keeping the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_RequiredMissingRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("rich_text without type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "type" is missing`,
|
||||
`expected type "string"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepMismatchRealSchema replays 场景4: a
|
||||
// numeric rich_text "type" three levels deep gets the field's enum inline
|
||||
// (no whole-shape skeleton), still with the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_DeepMismatchRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"type":42,"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("numeric rich_text type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`expected type "string", got "number"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema deep-mismatch error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "expected shape:") {
|
||||
t.Errorf("deep mismatch must not inline a skeleton; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregatesMultipleErrors pins the
|
||||
// aggregate path: a payload with several independent problems reports them
|
||||
// all in one numbered reply (each with its own teaching hint) instead of
|
||||
// the fail-fast fix-one-retry-hit-the-next loop.
|
||||
func TestValidateValueAgainstSchema_AggregatesMultipleErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Two independent problems in one --cells payload: cell[0][0].rich_text[0]
|
||||
// misses required "type"; cell[0][1].note has the wrong type.
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]},{"note":12.5}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with two problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"2 validation errors:",
|
||||
`1) required property "type" is missing`,
|
||||
`one of ["text"`, // teaching hint rides along in aggregate mode too
|
||||
`2) [0][1].note: expected type "string"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in aggregated error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregateCapTruncates pins the display
|
||||
// cap: a pathological payload reports schemaErrorDisplayLimit entries and
|
||||
// an explicit truncation tail, never the full flood.
|
||||
func TestValidateValueAgainstSchema_AggregateCapTruncates(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Seven cells all missing required rich_text "type" → 7 independent errors.
|
||||
row := make([]string, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
row = append(row, `{"rich_text":[{"text":"x"}]}`)
|
||||
}
|
||||
value := parseValue(t, `[[`+strings.Join(row, ",")+`]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with seven problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "5+ validation errors:") {
|
||||
t.Errorf("want capped header '5+ validation errors:'; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "more errors not shown") {
|
||||
t.Errorf("want truncation tail; got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "6)") {
|
||||
t.Errorf("must not render entries beyond the display limit; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectSchemaErrors_OneOfProbeDoesNotLeak pins that failed oneOf
|
||||
// alternatives don't leak probe errors into the caller's collector when a
|
||||
// later alternative matches.
|
||||
func TestCollectSchemaErrors_OneOfProbeDoesNotLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"oneOf":[{"type":"string"},{"type":"number"}]}`)
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(42.0, schema, "", c)
|
||||
if len(c.errs) != 0 {
|
||||
t.Errorf("number matches the second oneOf alternative; want no errors, got %v", c.errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneLineDescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := oneLineDescription(" "); got != "" {
|
||||
t.Errorf("whitespace-only → empty, got %q", got)
|
||||
}
|
||||
if got := oneLineDescription("line one\n line two"); got != "line one line two" {
|
||||
t.Errorf("multi-line collapse = %q", got)
|
||||
}
|
||||
long := strings.Repeat("x", 200)
|
||||
got := oneLineDescription(long)
|
||||
if !strings.HasSuffix(got, "…") || len([]rune(got)) != descriptionMaxLen+1 {
|
||||
t.Errorf("long description should truncate to %d runes + ellipsis, got %d", descriptionMaxLen, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
|
||||
@@ -34,6 +34,7 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+rows-resize": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+styles-put": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -333,6 +333,12 @@ func (m *mapFlagView) normalizeAndValidateEnums() error {
|
||||
m.raw[rawKey] = canonical
|
||||
continue
|
||||
}
|
||||
// A retired value means "as if omitted" — delete the key so Changed()
|
||||
// also reports it as absent, matching the standalone path.
|
||||
if isRetiredEnumValue(m.command, df.Name, value) {
|
||||
delete(m.raw, rawKey)
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("invalid value %q for --%s, allowed: %s", value, df.Name, strings.Join(df.Enum, ", "))
|
||||
if match := closestEnumValue(value, df.Enum); match != "" {
|
||||
message += fmt.Sprintf("; did you mean %q?", match)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
@@ -320,7 +319,12 @@ func requireSheetSelector(sheetID, sheetName string) error {
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID == "" && sheetName == "" {
|
||||
// Eval traces show every occurrence recovering on the next call, so
|
||||
// the gap is knowing WHICH name to pass, not that one is needed: a
|
||||
// just-created workbook has a single sheet named Sheet1, and any
|
||||
// other workbook needs one +workbook-info lookup.
|
||||
return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name").
|
||||
WithHint("a freshly created workbook has one sheet named Sheet1 (`--sheet-name Sheet1`); otherwise list the real sheets with `lark-cli sheets +workbook-info --url <URL>`").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "required; specify at least one"),
|
||||
sheetsInvalidParam("sheet-name", "required; specify at least one"),
|
||||
@@ -425,6 +429,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Unambiguous habitual shapes are rewritten onto the wire contract
|
||||
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
|
||||
// so both the standalone cobra path and +batch-update sub-ops (whose
|
||||
// mapFlagView.Str re-encodes composites through here) get the rewrite.
|
||||
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
|
||||
out = norm(out)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
// hooks fill in flat-flag-derived fields) and any flag without an
|
||||
@@ -435,6 +446,134 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
|
||||
// input shapes onto the wire contract before schema validation — same
|
||||
// contract as enum normalization: only a shape whose meaning is beyond
|
||||
// doubt may be rewritten; anything ambiguous must fail with a prescription
|
||||
// instead. Applied to the parsed JSON value inside parseJSONFlag.
|
||||
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
|
||||
"+cells-set": {"cells": normalizeCellsFlagValue},
|
||||
"+cells-set-style": {"border-styles": normalizeBorderStylesFlagValue},
|
||||
"+cells-batch-set-style": {"border-styles": normalizeBorderStylesFlagValue},
|
||||
"+chart-create": {"properties": normalizeChartHexColors},
|
||||
"+chart-update": {"properties": normalizeChartHexColors},
|
||||
}
|
||||
|
||||
// normalizeChartHexColors walks a chart properties payload and prefixes bare
|
||||
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
|
||||
// Excel-habit form the chart backend rejects with "expected rgba() or
|
||||
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
|
||||
// bare hex color is untouched.
|
||||
func normalizeChartHexColors(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, val := range t {
|
||||
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
|
||||
t[k] = "#" + s
|
||||
continue
|
||||
}
|
||||
// A color key can hold an ARRAY of colors (colorTheme, series
|
||||
// palettes). Recursing without the key would lose the color
|
||||
// context and leave bare hex strings unprefixed, so the server
|
||||
// rejects a payload the schema itself allows.
|
||||
if arr, ok := val.([]interface{}); ok && isColorKey(k) {
|
||||
normalizeChartHexColorList(arr)
|
||||
continue
|
||||
}
|
||||
normalizeChartHexColors(val)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, e := range t {
|
||||
normalizeChartHexColors(e)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeChartHexColorList prefixes bare hex strings inside an array that
|
||||
// sits under a color key, and keeps descending for nested shapes.
|
||||
func normalizeChartHexColorList(arr []interface{}) {
|
||||
for i, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
if isBareHexColor(s) {
|
||||
arr[i] = "#" + s
|
||||
}
|
||||
continue
|
||||
}
|
||||
if nested, ok := e.([]interface{}); ok {
|
||||
normalizeChartHexColorList(nested)
|
||||
continue
|
||||
}
|
||||
normalizeChartHexColors(e)
|
||||
}
|
||||
}
|
||||
|
||||
// isColorKey reports whether a key names a color (or a list of colors). The
|
||||
// value gate is isBareHexColor — a strict 6/8-digit hex check — so matching a
|
||||
// key generously is safe: a non-hex value under a color-ish key is left alone.
|
||||
// Plural and color-prefixed forms matter because the chart schema uses
|
||||
// colorTheme / colorScale / colorGradient / highlight_colors, none of which
|
||||
// end in "color".
|
||||
func isColorKey(k string) bool {
|
||||
if k == "color" || k == "colors" {
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{"_color", "Color", "_colors", "Colors"} {
|
||||
if strings.HasSuffix(k, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(k, "color") || strings.HasPrefix(k, "Color")
|
||||
}
|
||||
|
||||
func isBareHexColor(s string) bool {
|
||||
if len(s) != 6 && len(s) != 8 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cellObjectKeys pins the property vocabulary of a single cell in the
|
||||
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
|
||||
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
|
||||
var cellObjectKeys = map[string]struct{}{
|
||||
"border_styles": {},
|
||||
"cell_styles": {},
|
||||
"data_validation": {},
|
||||
"formula": {},
|
||||
"multiple_values": {},
|
||||
"note": {},
|
||||
"rich_text": {},
|
||||
"value": {},
|
||||
}
|
||||
|
||||
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
|
||||
// --cells contract expects. Eval traces show agents writing a single cell
|
||||
// routinely pass {"value":…} without the two array layers; when every key
|
||||
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
|
||||
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
|
||||
// row or a column) is returned untouched for the schema validator to
|
||||
// prescribe.
|
||||
func wrapLoneCellObject(v interface{}) interface{} {
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok || len(obj) == 0 {
|
||||
return v
|
||||
}
|
||||
for k := range obj {
|
||||
if _, known := cellObjectKeys[k]; !known {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return []interface{}{[]interface{}{obj}}
|
||||
}
|
||||
|
||||
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
|
||||
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
@@ -451,6 +590,51 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ─── aggregated sub-error rendering ────────────────────────────────────
|
||||
//
|
||||
// Several flags collect per-item failures and fold them into ONE typed error
|
||||
// (--styles, --writes, --operations). A Problem carries a single Hint slot,
|
||||
// so the naive fold — taking only each inner error's Message — silently drops
|
||||
// the very prescriptions this domain adds (requireSheetSelector's
|
||||
// "+workbook-info" pointer, the batch key contract). These two helpers keep
|
||||
// them: a lone failure hands its Hint to the outer error's Hint field, and a
|
||||
// folded list inlines each hint next to its own message.
|
||||
|
||||
// aggregatedIssueParts splits a collected sub-error into its message and its
|
||||
// hint ("" when it carries none), unwrapping the typed Problem so the message
|
||||
// is the bare text rather than the Error() rendering.
|
||||
func aggregatedIssueParts(err error) (msg, hint string) {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Message, p.Hint
|
||||
}
|
||||
return err.Error(), ""
|
||||
}
|
||||
|
||||
// aggregatedIssueText renders one collected sub-error for a folded, multi-issue
|
||||
// message, appending its hint in parentheses so a per-item prescription is not
|
||||
// lost to the single shared Hint slot.
|
||||
func aggregatedIssueText(err error) string {
|
||||
msg, hint := aggregatedIssueParts(err)
|
||||
if hint == "" {
|
||||
return msg
|
||||
}
|
||||
return msg + " (" + hint + ")"
|
||||
}
|
||||
|
||||
// prefixValidationIssue re-labels a collected sub-error with the path it was
|
||||
// found at ("--writes[2]"), keeping its Hint. Formatting the inner error into
|
||||
// a new message with "%v" would drop that hint on the floor — the collectors
|
||||
// only ever read Message and Hint, so the two must stay separate all the way
|
||||
// to the fold.
|
||||
func prefixValidationIssue(path string, err error) error {
|
||||
msg, hint := aggregatedIssueParts(err)
|
||||
out := common.ValidationErrorf("%s: %s", path, msg).WithCause(err)
|
||||
if hint != "" {
|
||||
out = out.WithHint("%s", hint)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// requireJSONArray is parseJSONFlag + a type assertion to []interface{}.
|
||||
func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
@@ -466,146 +650,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. path labels the map for the error message.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// Structure is checked leniently to match the pass-through contract: any
|
||||
// element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,13 @@ func TestSheetHelpersValidationMetadata(t *testing.T) {
|
||||
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
|
||||
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
|
||||
}
|
||||
// Eval traces recover on the very next call, so the missing piece is
|
||||
// which name to pass — the hint has to name Sheet1 and the lookup.
|
||||
for _, want := range []string{"Sheet1", "+workbook-info"} {
|
||||
if !strings.Contains(validationErr.Hint, want) {
|
||||
t.Errorf("hint should mention %q, got %q", want, validationErr.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
|
||||
@@ -224,6 +231,19 @@ func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface
|
||||
return calls
|
||||
}
|
||||
|
||||
// dryRunWarning returns the advisory text a dry-run surfaces under
|
||||
// data.warning_message, or "" when the shortcut emitted none.
|
||||
func dryRunWarning(t *testing.T, sc common.Shortcut, args []string) string {
|
||||
t.Helper()
|
||||
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
||||
}
|
||||
data, _ := decodeDryRunRaw(t, out)["data"].(map[string]interface{})
|
||||
warning, _ := data["warning_message"].(string)
|
||||
return warning
|
||||
}
|
||||
|
||||
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
|
||||
t.Helper()
|
||||
idx := strings.Index(out, "{")
|
||||
|
||||
312
shortcuts/sheets/json_flag_normalize_test.go
Normal file
312
shortcuts/sheets/json_flag_normalize_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
|
||||
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
|
||||
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
|
||||
// untouched for the schema validator to prescribe.
|
||||
func TestWrapLoneCellObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wrapped bool
|
||||
}{
|
||||
{"lone value cell", `{"value":"hi"}`, true},
|
||||
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
|
||||
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
|
||||
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
|
||||
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
|
||||
{"empty object stays", `{}`, false},
|
||||
{"scalar stays", `"hi"`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
|
||||
t.Fatalf("bad fixture: %v", err)
|
||||
}
|
||||
out := wrapLoneCellObject(v)
|
||||
_, isWrapped := out.([]interface{})
|
||||
_, wasArray := v.([]interface{})
|
||||
if tc.wrapped && (!isWrapped || wasArray) {
|
||||
t.Errorf("expected wrap to [[cell]], got %#v", out)
|
||||
}
|
||||
if !tc.wrapped && !wasArray && isWrapped {
|
||||
t.Errorf("expected no wrap, got %#v", out)
|
||||
}
|
||||
if tc.wrapped {
|
||||
rows, _ := out.([]interface{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(rows))
|
||||
}
|
||||
cells, _ := rows[0].([]interface{})
|
||||
if len(cells) != 1 {
|
||||
t.Fatalf("want 1 cell, got %d", len(cells))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
|
||||
// vocabulary against the embedded +cells-set --cells schema: if the spec
|
||||
// repo adds or removes a cell property, this fails and cellObjectKeys must
|
||||
// be updated (an outdated set only narrows the auto-wrap, but silently
|
||||
// narrowing is still drift).
|
||||
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas: %v", err)
|
||||
}
|
||||
raw, ok := idx.Flags["+cells-set"]["cells"]
|
||||
if !ok {
|
||||
t.Fatal("embedded schema for +cells-set --cells missing")
|
||||
}
|
||||
var schema schemaProperty
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
cell := schema.Items
|
||||
if cell != nil && cell.Items != nil {
|
||||
cell = cell.Items
|
||||
}
|
||||
if cell == nil || len(cell.Properties) == 0 {
|
||||
t.Fatal("schema shape changed: expected array→array→object with properties")
|
||||
}
|
||||
for k := range cell.Properties {
|
||||
if _, ok := cellObjectKeys[k]; !ok {
|
||||
t.Errorf("schema property %q missing from cellObjectKeys", k)
|
||||
}
|
||||
}
|
||||
for k := range cellObjectKeys {
|
||||
if _, ok := cell.Properties[k]; !ok {
|
||||
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
|
||||
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
|
||||
// instead of failing "expected type array, got object".
|
||||
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `{"value":"hello"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_BorderWeightWordInStyleNormalizes pins the reachability
|
||||
// fix for the border acceptance layer on the --border-styles flag path: the
|
||||
// eval-trace failure shape ({"style":"thin"} — 07-28 root-cause report #2,
|
||||
// 173 occurrences) must normalize to style:solid + weight:thin BEFORE the
|
||||
// schema enum check, instead of dying on `value "thin" is not in enum`.
|
||||
func TestCellsSetStyle_BorderWeightWordInStyleNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
|
||||
t.Run("full nested form with weight word in style", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:B2",
|
||||
"--border-styles", `{"top":{"style":"thin","color":"#B4B4B4"},"bottom":{"style":"thin","color":"#B4B4B4"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("weight word in style slot should normalize, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"style": "solid"`, `"weight": "thin"`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all shorthand with weight word in style", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--border-styles", `{"all":{"style":"medium","color":"#000000"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("all shorthand + weight word should normalize, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"top"`, `"bottom"`, `"weight": "medium"`, `"style": "solid"`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit conflicting weight keeps the enum error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--border-styles", `{"top":{"style":"thin","weight":"thick"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "not in enum")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSet_BorderWeightWordInStyleNormalizes pins the same reachability
|
||||
// fix on the typed --cells carrier (07-28 root-cause report #10, 58
|
||||
// occurrences): border_styles inside a cell object normalizes before the
|
||||
// enum check.
|
||||
func TestCellsSet_BorderWeightWordInStyleNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","border_styles":{"top":{"style":"thin","color":"#000000"}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("weight word in style slot should normalize on --cells, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"style": "solid"`, `"weight": "thin"`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
|
||||
// wrong JSON kind inlines the expected shape; mangled JSON steers to
|
||||
// stdin/@file.
|
||||
func TestTablePut_SheetsDecodeHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare array names the missing envelope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `[{"name":"s","columns":["a"],"data":[["x"]]}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
// The Go unmarshal text names the internal struct, not the fix
|
||||
// (07-28 root-cause report #4, 84 occurrences).
|
||||
ve := requireValidation(t, err, `top level must be the object {"sheets":[…]}`)
|
||||
if strings.Contains(ve.Message, "cannot unmarshal") {
|
||||
t.Errorf("message should not leak the Go unmarshal wording, got %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "expected shape:") {
|
||||
t.Errorf("hint should still inline the skeleton, got %q", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[)`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"stdin", "@./payload.json"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
|
||||
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
|
||||
// pass-through of everything else, including the parseJSONFlag wiring for
|
||||
// the batch sub-op path.
|
||||
func TestNormalizeChartHexColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := map[string]interface{}{
|
||||
"plotArea": map[string]interface{}{
|
||||
"plot": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
|
||||
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
|
||||
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
|
||||
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
normalizeChartHexColors(props)
|
||||
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
|
||||
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
|
||||
t.Errorf("bare hex should gain #, got %v", got)
|
||||
}
|
||||
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
|
||||
t.Errorf("already-prefixed color must not change, got %v", got)
|
||||
}
|
||||
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
|
||||
t.Errorf("rgba color must not change, got %v", got)
|
||||
}
|
||||
last := series[3].(map[string]interface{})
|
||||
if got := last["font_color"]; got != "#ED7D31AA" {
|
||||
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
|
||||
}
|
||||
if got := last["label"]; got != "not a color 4472C4" {
|
||||
t.Errorf("non-color key must not change, got %v", got)
|
||||
}
|
||||
|
||||
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
|
||||
// and picks up the normalizer.
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
|
||||
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
|
||||
})
|
||||
out, err := parseJSONFlag(fv, "properties")
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONFlag: %v", err)
|
||||
}
|
||||
title := out.(map[string]interface{})["title"].(map[string]interface{})
|
||||
if title["font_color"] != "#112233" {
|
||||
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -29,10 +30,14 @@ import (
|
||||
// The tool's contract (post-translation):
|
||||
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
|
||||
//
|
||||
// continue_on_error defaults to false (strict transaction): any failure
|
||||
// rolls back the whole batch. CLI leaves the default in place for the
|
||||
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
|
||||
// only +batch-update lets callers flip it via --continue-on-error.
|
||||
// continue_on_error defaults to false (fail-fast): execution stops at the
|
||||
// first failing sub-op, but sub-ops already applied are NOT rolled back —
|
||||
// the server reports "N succeeded, M failed" and the N stay in the sheet
|
||||
// (verified against live batches; earlier docs wrongly promised a rollback,
|
||||
// which made agents resend whole batches and double-apply the successes).
|
||||
// CLI leaves the default in place for the fan-out shortcuts since they're
|
||||
// idempotent stamps; only +batch-update lets callers flip it via
|
||||
// --continue-on-error.
|
||||
|
||||
// BatchUpdate accepts a CLI-shape operations array (each item
|
||||
// {shortcut, input}); on Validate / DryRun / Execute we translate each
|
||||
@@ -42,7 +47,7 @@ import (
|
||||
var BatchUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-update",
|
||||
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
|
||||
Description: "Execute a batch of write shortcuts in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -64,7 +69,11 @@ var BatchUpdate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := batchUpdateInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
if warnings := batchWarnings(runtime); len(warnings) > 0 {
|
||||
dr.Set("warning_message", strings.Join(warnings, "\n"))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -75,6 +84,9 @@ var BatchUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range batchWarnings(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, w)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,7 +95,8 @@ var BatchUpdate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
|
||||
"high-risk-write: preview with --dry-run, get the user's explicit consent, then re-run with --yes appended — do not pass --yes before the user has confirmed (without it the call exits 10 asking for confirmation).",
|
||||
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
|
||||
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
|
||||
},
|
||||
}
|
||||
@@ -124,6 +137,171 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
|
||||
// requests --inherit-style before at the first row/column, where the
|
||||
// preceding-side style cannot be copied (no preceding row/column exists).
|
||||
// batchWarnings collects the advisory notes a batch surfaces before it runs,
|
||||
// in one place so DryRun and Execute cannot drift apart on which ones they
|
||||
// report.
|
||||
func batchWarnings(runtime *common.RuntimeContext) []string {
|
||||
var out []string
|
||||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||||
out = append(out, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out = append(out, batchCollidingDimFreezeNotes(runtime)...)
|
||||
return append(out, batchLegacyDimFreezeNotes(runtime)...)
|
||||
}
|
||||
|
||||
// batchCollidingDimFreezeNotes reports +dim-freeze sub-ops that target the SAME
|
||||
// sheet more than once. Freeze is full-state replacement, so each of them
|
||||
// discards the previous one and only the last survives — both still report
|
||||
// success, which is exactly why the mistake goes unnoticed. The CLI has already
|
||||
// walked the whole ops array by this point, so it can name the survivor and the
|
||||
// single sub-op that holds everything the caller clearly meant to hold.
|
||||
//
|
||||
// A batch cannot read current state, and +styles-put (the other combined-freeze
|
||||
// carrier) is not batchable, so folding into ONE sub-op is the only fix — hence
|
||||
// a note rather than a suggestion to reorder.
|
||||
func batchCollidingDimFreezeNotes(runtime *common.RuntimeContext) []string {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return nil // a malformed --operations is the translator's to report.
|
||||
}
|
||||
type freezeOp struct {
|
||||
index int
|
||||
rows, cols int
|
||||
}
|
||||
// Keyed by the sub-op's sheet selector: freezes on different sheets are
|
||||
// independent. Order of first appearance keeps the notes deterministic.
|
||||
bySheet := map[string][]freezeOp{}
|
||||
var order []string
|
||||
for i, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
if input == nil {
|
||||
continue
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+dim-freeze", input)
|
||||
rows, cols, ok := dimFreezeAxes(fv)
|
||||
if !ok {
|
||||
continue // an unusable sub-op is the translator's to report.
|
||||
}
|
||||
key := strings.TrimSpace(fv.Str("sheet-id")) + "\x00" + strings.TrimSpace(fv.Str("sheet-name"))
|
||||
if _, seen := bySheet[key]; !seen {
|
||||
order = append(order, key)
|
||||
}
|
||||
bySheet[key] = append(bySheet[key], freezeOp{index: i, rows: rows, cols: cols})
|
||||
}
|
||||
|
||||
var notes []string
|
||||
for _, key := range order {
|
||||
ops := bySheet[key]
|
||||
if len(ops) < 2 {
|
||||
continue
|
||||
}
|
||||
indexes := make([]string, 0, len(ops))
|
||||
// The combined state is what the caller almost certainly meant: keep the
|
||||
// last positive value named for each axis. An axis nobody ever freezes
|
||||
// stays 0, so a deliberate "unfreeze everything" batch still renders as
|
||||
// --rows 0 --cols 0 rather than inventing a freeze.
|
||||
combinedRows, combinedCols := 0, 0
|
||||
for _, op := range ops {
|
||||
indexes = append(indexes, fmt.Sprintf("operations[%d]", op.index))
|
||||
if op.rows > 0 {
|
||||
combinedRows = op.rows
|
||||
}
|
||||
if op.cols > 0 {
|
||||
combinedCols = op.cols
|
||||
}
|
||||
}
|
||||
last := ops[len(ops)-1]
|
||||
notes = append(notes, fmt.Sprintf(
|
||||
"warning: %s are all +dim-freeze on the same sheet — freeze replaces the WHOLE state, so each one discards the previous and only %s survives (ending at %s). They all report success. Replace them with ONE sub-op: %s",
|
||||
strings.Join(indexes, ", "),
|
||||
indexes[len(indexes)-1],
|
||||
dimFreezeSpelling(last.rows, last.cols),
|
||||
dimFreezeSpelling(combinedRows, combinedCols)))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// batchLegacyDimFreezeNotes steers +dim-freeze sub-ops still written in the
|
||||
// deprecated --dimension/--count form (see DEPRECATED(phase-2) on
|
||||
// dimFreezeLegacyNote). The standalone command prints that note from its own
|
||||
// DryRun/Execute, which a sub-op never reaches — yet the batch is where the
|
||||
// legacy form does the most damage: freeze is full-state replacement, so two
|
||||
// per-axis sub-ops both report success while only the last axis stays frozen,
|
||||
// and +styles-put (the other way to set both axes) is not batchable. The
|
||||
// wording comes from the shared helper, so it cannot drift from the standalone
|
||||
// one.
|
||||
func batchLegacyDimFreezeNotes(runtime *common.RuntimeContext) []string {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return nil // a malformed --operations is the translator's to report.
|
||||
}
|
||||
var notes []string
|
||||
for i, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
if input == nil {
|
||||
continue
|
||||
}
|
||||
if note := dimFreezeLegacyNote(newMapFlagViewForCommand("+dim-freeze", input)); note != "" {
|
||||
notes = append(notes, fmt.Sprintf("operations[%d] (+dim-freeze): %s", i, note))
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sc, _ := op["shortcut"].(string)
|
||||
if sc != "+dim-insert" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
isBefore := false
|
||||
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
|
||||
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
|
||||
isBefore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isBefore {
|
||||
continue
|
||||
}
|
||||
posRaw, hasPos := input["position"]
|
||||
if !hasPos {
|
||||
continue
|
||||
}
|
||||
// Warn only at the first row/column (idx 0).
|
||||
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
|
||||
// operations list directly) or an envelope object { operations, continue_on_error }
|
||||
// for back-compat with the legacy --data shape. Returns the operations array.
|
||||
@@ -154,12 +332,17 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
|
||||
var CellsBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-batch-set-style",
|
||||
Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.",
|
||||
Description: "Apply one style block to many sheet-prefixed ranges in one batch request (fail-fast, no rollback).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-set-style"),
|
||||
Tips: []string{
|
||||
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
|
||||
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
|
||||
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
@@ -189,6 +372,14 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// DEPRECATED(phase-2): +cells-batch-set-style — replaced by +styles-put.
|
||||
// Phase 1 (here): the command keeps working and is already retired from
|
||||
// the skill docs via bundle.json doc_hidden_shortcuts in
|
||||
// sheet-skill-spec; steer new usage to the superset in-band.
|
||||
// Phase 2 removal: drop the shortcut from spec-tables + its
|
||||
// doc_hidden_shortcuts entry, then this command and its input builder.
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -230,7 +421,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
@@ -258,7 +449,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
var CellsBatchClear = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-batch-clear",
|
||||
Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).",
|
||||
Description: "Clear content/formats across many sheet-prefixed ranges in one batch request (irreversible; fail-fast, no rollback).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -334,7 +525,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
|
||||
var DropdownUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-update",
|
||||
Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.",
|
||||
Description: "Install or replace one dropdown across many sheet-prefixed ranges in one batch request (fail-fast, no rollback).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -380,7 +571,7 @@ var DropdownUpdate = common.Shortcut{
|
||||
var DropdownDelete = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-delete",
|
||||
Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).",
|
||||
Description: "Clear dropdowns from many sheet-prefixed ranges in one batch request (irreversible; fail-fast, no rollback).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -452,7 +643,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
@@ -484,10 +675,10 @@ const maxBatchRanges = 100
|
||||
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
|
||||
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
|
||||
// summing past it. totalCells is int64 to stay overflow-safe.
|
||||
func checkBatchStampBudget(totalCells int64) error {
|
||||
func checkBatchStampBudget(flagName string, totalCells int64) error {
|
||||
if totalCells > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag("ranges",
|
||||
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"the request expands to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -58,6 +58,39 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_DimInsertInheritAfterCopiesFollowingStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"D","count":1,"inherit_style":"after"}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "modify_sheet_structure" {
|
||||
t.Fatalf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
|
||||
}
|
||||
in, _ := op["input"].(map[string]interface{})
|
||||
// inherit_style=after copies the following column's style via a plain
|
||||
// before-insert at the same position (the backend anchors on the following
|
||||
// column), so position stays D with side=before.
|
||||
assertInputEquals(t, in, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": "sh1",
|
||||
"operation": "insert",
|
||||
"position": "D",
|
||||
"count": float64(1),
|
||||
"side": "before",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
@@ -405,6 +438,21 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
|
||||
wantMatch: "'input' must be a JSON object",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_styles":{"background_color":"#EBF1F8"}}}]`,
|
||||
wantMatch: "do not wrap in cell_styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","styles":{"font_weight":"bold"}}}]`,
|
||||
wantMatch: "do not wrap in styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_merges structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_merges":[{"range":"A1:B1"}]}}]`,
|
||||
wantMatch: "do not wrap in cell_merges",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -420,6 +468,99 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper guards the
|
||||
// wrapped-structure rejection against overreach: the same style fields in
|
||||
// their correct flattened form must translate cleanly — only the wrapper
|
||||
// container keys (cell_styles / styles / cell_merges) are rejected.
|
||||
func TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet_name": "s",
|
||||
"range": "A1",
|
||||
"background_color": "#EBF1F8",
|
||||
"font_weight": "bold",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("flattened style keys must pass the wrapper check, got %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
||||
if style["background_color"] != "#EBF1F8" || style["font_weight"] != "bold" {
|
||||
t.Fatalf("translated style = %#v", style)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags locks the static
|
||||
// assumption wrappedSubOpInputKeys relies on: no shortcut registered in
|
||||
// batchOpDispatch declares a flag named cell_styles / cell_merges / styles.
|
||||
// If a future dispatch-table addition (e.g. +table-put) carries one of these
|
||||
// flags, its legitimate input would be silently rejected by the wrapper
|
||||
// check — this test turns that silent breakage into a build-time failure.
|
||||
func TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
wrapped := make(map[string]struct{}, len(wrappedSubOpInputKeys))
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
wrapped[k] = struct{}{}
|
||||
}
|
||||
for shortcut := range batchOpDispatch {
|
||||
for _, f := range flagsFor(shortcut) {
|
||||
key := strings.ReplaceAll(f.Name, "-", "_")
|
||||
if _, clash := wrapped[key]; clash {
|
||||
t.Errorf("%s declares flag --%s which collides with wrappedSubOpInputKeys; "+
|
||||
"exempt this shortcut from the wrapper check before adding it to batchOpDispatch",
|
||||
shortcut, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_AggregatesMultipleOpErrors pins op-level aggregation: when
|
||||
// several operations are invalid, one reply names them all (numbered, with
|
||||
// each op's own error) instead of failing on the first bad op only. A single
|
||||
// bad op keeps the historical single-error message (no aggregate wrapper).
|
||||
func TestBatchUpdate_AggregatesMultipleOpErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops reported together", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-set","input":{"sheet_name":"s","range":"A1"}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"1) ", "2) ", "operations[0]", "operations[1]"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("aggregated op error should contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps plain message", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "not allowed in +batch-update")
|
||||
if strings.Contains(err.Error(), "operations failed validation") {
|
||||
t.Errorf("single bad op must not get the aggregate wrapper, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
|
||||
// highest-frequency batch failures, so an agent can repair its payload in a
|
||||
// single retry without --help / --print-schema round trips.
|
||||
@@ -588,3 +729,145 @@ func TestSplitSheetPrefixedRange(t *testing.T) {
|
||||
// Compile-time use of json import
|
||||
_ = json.Marshal
|
||||
}
|
||||
|
||||
// TestBatchUpdate_CollidingDimFreezeWarns covers the failure mode the legacy
|
||||
// deprecation note alone could not surface: two +dim-freeze sub-ops on one
|
||||
// sheet. Freeze is full-state replacement, so the second silently discards the
|
||||
// first — and BOTH report success, which is why it goes unnoticed. Per-op
|
||||
// "equivalent to --rows 1" / "equivalent to --cols 2" notes do not say that;
|
||||
// the caller has to infer the interaction. This pins that the CLI states it.
|
||||
func TestBatchUpdate_CollidingDimFreezeWarns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two per-axis freezes on one sheet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1}},
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","cols":2}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"operations[0], operations[1]",
|
||||
"only operations[1] survives",
|
||||
"--cols 2)", // the state actually reached
|
||||
"ONE sub-op: --rows 1 --cols 2", // the fix
|
||||
} {
|
||||
if !strings.Contains(warning, want) {
|
||||
t.Errorf("collision warning should contain %q, got %q", want, warning)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy spelling collides the same way and keeps its own note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","dimension":"row","count":1}},
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","dimension":"column","count":2}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
if !strings.Contains(warning, "ONE sub-op: --rows 1 --cols 2") {
|
||||
t.Errorf("legacy spelling should collide too, got %q", warning)
|
||||
}
|
||||
if !strings.Contains(warning, "superseded by --rows/--cols") {
|
||||
t.Errorf("per-op deprecation note should still ride along, got %q", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different sheets do not collide", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1}},
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S2","cols":2}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
if strings.Contains(warning, "same sheet") {
|
||||
t.Errorf("freezes on different sheets are independent, got %q", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a single freeze warns about nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1,"cols":2}}]`,
|
||||
"--yes",
|
||||
})
|
||||
if warning != "" {
|
||||
t.Errorf("one combined freeze is the correct form, got warning %q", warning)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOpAliasCollidesWithTarget pins the message for a sub-op carrying
|
||||
// BOTH an intuitive alias and the flag it aliases. The key is recognized, so
|
||||
// reporting it as "unknown input key" (which it did, because keys are walked
|
||||
// in sorted order and "size" sorts before "width", leaving nothing to conflict
|
||||
// with yet) sent the caller looking for a typo that was not there.
|
||||
func TestBatchOpAliasCollidesWithTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conflicting values name both spellings", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]interface{}{"sheet_name": "S1", "range": "A:C", "size": float64(100), "width": float64(120)}
|
||||
err := normalizeSubOpInputKeys("+cols-resize", input)
|
||||
if err == nil {
|
||||
t.Fatal("want an error for size + width with different values")
|
||||
}
|
||||
for _, want := range []string{`"size"`, `"width"`, "same flag"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "unknown input key") {
|
||||
t.Errorf("an aliased key is not unknown, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same value under both spellings drops the alias", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]interface{}{"sheet_name": "S1", "range": "A:C", "size": float64(120), "width": float64(120)}
|
||||
if err := normalizeSubOpInputKeys("+cols-resize", input); err != nil {
|
||||
t.Fatalf("identical values are harmless, got %v", err)
|
||||
}
|
||||
if _, still := input["size"]; still {
|
||||
t.Errorf("the alias should be dropped, got %#v", input)
|
||||
}
|
||||
if input["width"] != float64(120) {
|
||||
t.Errorf("width = %#v, want 120", input["width"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchUpdate_AggregatedErrorsKeepHints pins that folding several bad
|
||||
// sub-ops into one message does not cost the caller the per-shortcut key
|
||||
// contract each single-op error carries — otherwise the more mistakes you
|
||||
// make, the less guidance you get.
|
||||
func TestBatchUpdate_AggregatedErrorsKeepHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL, "--yes",
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set","input":{"sheet_name":"S1","bogus":1}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"S1","nope":2}}
|
||||
]`,
|
||||
})
|
||||
ve := requireValidation(t, err, "2 of 2 operations failed validation")
|
||||
for _, want := range []string{
|
||||
"+cells-set input keys:",
|
||||
"+cells-clear input keys:",
|
||||
} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("aggregated message should inline %q, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
|
||||
var RowsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+rows-resize",
|
||||
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one atomic call, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one batch request, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -260,15 +260,19 @@ var RowsResize = common.Shortcut{
|
||||
var ColsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cols-resize",
|
||||
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one atomic call, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one batch request, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
|
||||
`Different widths per column in one batch request: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
|
||||
},
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
|
||||
@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,17 +87,19 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
|
||||
// read cap. Pin cell_limit very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["cell_limit"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
||||
// tool's two coarse switches:
|
||||
// tool's switches:
|
||||
//
|
||||
// - include_styles (bool) — toggled by "style" presence
|
||||
// - value_render_option (enum) — "formula" → formula; otherwise omitted
|
||||
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
|
||||
// the tool estimate and return per-cell isRowTruncated / isColTruncated
|
||||
//
|
||||
// "value", "comment", and "data_validation" are always returned by the tool
|
||||
// per the schema; they have no dedicated knob today but are accepted in
|
||||
@@ -119,6 +120,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
|
||||
if want["formula"] {
|
||||
input["value_render_option"] = "formula"
|
||||
}
|
||||
if want["truncation"] {
|
||||
input["include_truncation_info"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
|
||||
@@ -139,9 +143,6 @@ var CsvGet = common.Shortcut{
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return sheetsValidationForFlag("range", "--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -165,16 +166,25 @@ var CsvGet = common.Shortcut{
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
// csvGetFullSheetRange is the range sent when --range is omitted: the tool
|
||||
// requires one, but clips anything past the grid bounds and reports the clip
|
||||
// in actual_range — so an over-wide whole-columns range reads the entire
|
||||
// sheet in one call, with no workbook-info pre-flight. Eval traces show
|
||||
// "read the whole sheet" as a recurring intent (--range was the single most
|
||||
// missed required flag once the rest of the surface was fixed).
|
||||
const csvGetFullSheetRange = "A:ZZZ"
|
||||
|
||||
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
} else {
|
||||
input["range"] = csvGetFullSheetRange
|
||||
}
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
@@ -183,7 +193,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
|
||||
// read cap. Pin max_rows very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["max_rows"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
|
||||
@@ -34,6 +34,65 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-get include=formula without style pins include_styles=false",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "formula"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": false,
|
||||
"value_render_option": "formula",
|
||||
"cell_limit": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// --include truncation toggles include_truncation_info so the tool
|
||||
// estimates and returns per-cell isRowTruncated / isColTruncated.
|
||||
name: "+cells-get include=truncation",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": false,
|
||||
"include_truncation_info": true,
|
||||
"cell_limit": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// --output-path alone raises the cap to the bounded file-offload
|
||||
// default — NOT the unbounded sentinel; the read path is not
|
||||
// streaming, so the cap is the OOM guard.
|
||||
name: "+cells-get output-path uses bounded offload cap",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--output-path", "out.json"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"max_chars": float64(outputPathReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// An explicit --max-chars survives --output-path instead of being
|
||||
// silently replaced by the unbounded sentinel.
|
||||
name: "+cells-get explicit max-chars survives output-path",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--output-path", "out.json", "--max-chars", "12345"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"max_chars": float64(12345),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Canonical form: --sheet-id + bare --range. Aligned with
|
||||
// +cells-get / +csv-get; before the e2e BUG-019 fix this
|
||||
@@ -92,7 +151,9 @@ func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
|
||||
// TestReadData_RequiresRange covers the trim-based --range guard on the
|
||||
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
|
||||
// must still be rejected by Validate).
|
||||
// must still be rejected by Validate). +csv-get is deliberately absent:
|
||||
// its --range is optional — omitted/blank means a whole-sheet read (see
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet).
|
||||
func TestReadData_RequiresRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -100,7 +161,6 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
sc common.Shortcut
|
||||
}{
|
||||
{"+cells-get", CellsGet},
|
||||
{"+csv-get", CsvGet},
|
||||
{"+dropdown-get", DropdownGet},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -114,6 +174,23 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
|
||||
// with --range omitted the request carries the over-wide clip range, so a
|
||||
// full read needs no workbook-info pre-flight (eval: --range was the most
|
||||
// missed required flag on +csv-get once the rest of the surface settled).
|
||||
func TestCsvGet_RangeOptionalDefaultsToFullSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, CsvGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rangeless +csv-get must pass validation, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, csvGetFullSheetRange) {
|
||||
t.Fatalf("dry-run body should carry the full-sheet range %q, got %q", csvGetFullSheetRange, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
|
||||
// directly (white-box).
|
||||
func TestInfoTypeFromInclude(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,12 +129,29 @@ var DimInsert = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-insert"),
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
|
||||
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
|
||||
},
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
switch {
|
||||
case dimInsertNeedsBeforeStyleWarning(runtime):
|
||||
dr.Set("warning_message", dimInsertBeforeStyleWarning)
|
||||
case dimInsertAnchorShifted(runtime, input):
|
||||
// --inherit-style before anchors one unit earlier (see
|
||||
// dimInsertInput), so the previewed body carries a position the
|
||||
// caller never typed. Unexplained, that reads as an off-by-one bug in
|
||||
// exactly the artifact people dry-run to check for off-by-one bugs.
|
||||
dr.Set("warning_message", fmt.Sprintf(
|
||||
"note: the previewed position is %q, not the %q you passed — this is not an off-by-one. --inherit-style before is emulated by anchoring one row/column earlier and inserting after it, which lands in the same place while copying the PRECEDING style. The row/column still appears at %q.",
|
||||
input["position"], strings.TrimSpace(runtime.Str("position")), strings.TrimSpace(runtime.Str("position"))))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -148,6 +166,9 @@ var DimInsert = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dimInsertNeedsBeforeStyleWarning(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,8 +178,41 @@ var DimInsert = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
|
||||
// be copied: --inherit-style before at the first row/column, where no
|
||||
// preceding row/column exists. The row/column is still inserted before
|
||||
// --position, just without style inheritance. (--inherit-style after has no
|
||||
// such edge — a plain before-insert always has a following row/column.)
|
||||
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
|
||||
|
||||
// dimInsertAnchorShifted reports whether the built body carries an anchor
|
||||
// position different from the one the caller passed — true exactly when the
|
||||
// --inherit-style before emulation moved it back one unit. Compared against the
|
||||
// built input rather than recomputed, so the note can never claim a shift the
|
||||
// request does not have.
|
||||
func dimInsertAnchorShifted(runtime flagView, input map[string]interface{}) bool {
|
||||
built, ok := input["position"].(string)
|
||||
return ok && built != strings.TrimSpace(runtime.Str("position"))
|
||||
}
|
||||
|
||||
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
|
||||
if !runtime.Changed("inherit-style") || runtime.Str("inherit-style") != "before" {
|
||||
return false
|
||||
}
|
||||
// Only the first row/column (idx 0) has no preceding row/column.
|
||||
_, idx, err := parseA1Position(strings.TrimSpace(runtime.Str("position")))
|
||||
return err == nil && idx == 0
|
||||
}
|
||||
|
||||
// dimInsertInput passes --position (1-based row number "3" or column letter
|
||||
// "C") straight to the tool's `position` field; --count maps to `count`.
|
||||
// "C") to the tool's `position` field; --count maps to `count`.
|
||||
//
|
||||
// +dim-insert's public contract is always "insert before --position";
|
||||
// --inherit-style only selects which side's style the new row/column copies,
|
||||
// never the insertion side. The sheet-ai tool always copies the *anchor*
|
||||
// column's style (the target passed as position), regardless of side — so
|
||||
// --inherit-style before is emulated by anchoring one unit earlier. See the
|
||||
// switch below.
|
||||
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -184,11 +238,36 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
"count": count,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
// --inherit-style selects which side's style the blank row/column copies;
|
||||
// the insertion always lands *before* --position. Empirically the addCol
|
||||
// backend copies the *anchor* column's style (the target passed as
|
||||
// position), regardless of side — side only decides whether the blank lands
|
||||
// before or after that anchor (verified live, see
|
||||
// TestDimInsertInheritStyleSideMapping):
|
||||
// after → side=before at P: the blank lands at P and anchor P becomes the
|
||||
// *following* neighbour, so the blank copies it. Position unchanged.
|
||||
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
|
||||
// == insert-before-P) and anchor P-1 becomes the *preceding*
|
||||
// neighbour, so the blank copies it.
|
||||
//
|
||||
// The flag documents `after` as its default, and the omitted case takes that
|
||||
// branch rather than leaving `side` off the request. This is belt-and-braces,
|
||||
// not a fix: the backend's own default IS `before`, verified live 07-31 on a
|
||||
// 4-way sheet (omitted / after / before / no-side-at-all all place the blank
|
||||
// at --position, and omitted inherits the FOLLOWING row's style exactly as
|
||||
// `after` does). Sending it explicitly just stops the documented default from
|
||||
// depending on an undocumented server-side one.
|
||||
// Pinned by TestDimInsertOmittedMatchesAfter.
|
||||
switch runtime.Str("inherit-style") {
|
||||
case "before":
|
||||
if prev, ok := a1PositionBefore(position); ok {
|
||||
input["side"] = "after"
|
||||
input["position"] = prev
|
||||
}
|
||||
// First row/column: no preceding row/column exists, so fall back to a
|
||||
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
|
||||
default: // "after", and the omitted case it is the default for.
|
||||
input["side"] = "before"
|
||||
case "after":
|
||||
input["side"] = "after"
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -203,10 +282,34 @@ var DimDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-delete"),
|
||||
Validate: validateDimRangeOp("delete"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("ranges") {
|
||||
if runtime.Changed("range") {
|
||||
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
|
||||
}
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
}
|
||||
return validateDimRangeOp("delete")(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
if runtime.Changed("ranges") {
|
||||
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
@@ -219,6 +322,21 @@ var DimDelete = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("ranges") {
|
||||
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -232,9 +350,76 @@ var DimDelete = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"Row/column deletion is irreversible. Always preview with --dry-run first.",
|
||||
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one batch request (fail-fast, no rollback) — the CLI orders positions descending, so indexes never shift under you.`,
|
||||
},
|
||||
}
|
||||
|
||||
// dimDeleteRangesOps parses --ranges into one atomic batch of
|
||||
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
|
||||
// deleting an earlier row shifts every later index up, so ascending
|
||||
// execution deletes the wrong rows — the recurring failure of hand-built
|
||||
// dim-delete batches in eval traces. Same-dimension and non-overlap are
|
||||
// enforced up front.
|
||||
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "ranges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
|
||||
}
|
||||
if len(raw) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
|
||||
}
|
||||
type span struct {
|
||||
raw string
|
||||
start, end int
|
||||
}
|
||||
spans := make([]span, 0, len(raw))
|
||||
dimension := ""
|
||||
for i, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
|
||||
}
|
||||
dim, start, end, err := parseA1Range(s)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
|
||||
}
|
||||
if dimension == "" {
|
||||
dimension = dim
|
||||
} else if dim != dimension {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
|
||||
}
|
||||
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
|
||||
for i := 1; i < len(spans); i++ {
|
||||
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
|
||||
// (or duplicate) makes the later delete hit already-shifted positions.
|
||||
if spans[i].end >= spans[i-1].start {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
|
||||
}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(spans))
|
||||
for _, sp := range spans {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "delete",
|
||||
"range": sp.raw,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "modify_sheet_structure",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// validateDimRangeOp returns a Validate closure that delegates to
|
||||
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
|
||||
// extra `op` argument. Token check happens here; the rest is the builder.
|
||||
@@ -281,23 +466,37 @@ var DimUngroup = newDimGroupShortcut(
|
||||
"+dim-ungroup", "Remove a row/column outline group.", "ungroup",
|
||||
)
|
||||
|
||||
// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that
|
||||
// dimension.
|
||||
// DimFreeze sets the sheet's freeze state. Freeze is full-state replacement
|
||||
// server-side (verified 07-31 live), so every call states the WHOLE state:
|
||||
// --rows/--cols name both axes at once, while the older --dimension/--count
|
||||
// pair can only name one and therefore unfreezes the other.
|
||||
var DimFreeze = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dim-freeze",
|
||||
Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.",
|
||||
Description: "Freeze the first N rows and/or columns; this sets the whole freeze state, so an axis you do not name ends up unfrozen.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-freeze"),
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --rows 1 --cols 2 (holds the header row and the first 2 columns in one call)",
|
||||
"Freezing is not additive: --dimension row --count 1 followed by --dimension column --count 2 leaves ONLY the columns frozen. Pass --rows/--cols together instead of calling twice",
|
||||
"To unfreeze one axis but keep the other, state the survivor: --rows 0 --cols 2. Bare --count 0 clears both",
|
||||
},
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimFreezeInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
// Surface the deprecation steer during the preview too: agents dry-run
|
||||
// before executing, so a note only on the execute path arrives after the
|
||||
// spelling is already committed to.
|
||||
if note := dimFreezeLegacyNote(runtime); note != "" {
|
||||
dr.Set("warning_message", note)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -312,6 +511,9 @@ var DimFreeze = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if note := dimFreezeLegacyNote(runtime); note != "" {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, note)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -321,33 +523,151 @@ var DimFreeze = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// DEPRECATED(phase-2): +dim-freeze --dimension / --count — replaced by
|
||||
// --rows / --cols. Phase 1: the flags keep working, are retired from the skill
|
||||
// docs via bundle.json doc_hidden_flags in sheet-skill-spec and from --help via
|
||||
// their hidden mark, and every use is steered by dimFreezeLegacyNote.
|
||||
// Phase 2 removal: drop both rows from spec-tables/flags.json + their
|
||||
// doc_hidden_flags entry, then dimFreezeLegacyNote, dimFreezeEquivalent, their
|
||||
// call sites (this shortcut's DryRun/Execute and batchLegacyDimFreezeNotes) and
|
||||
// the legacy branch in dimFreezeInput.
|
||||
//
|
||||
// The pair is a strict subset of --rows/--cols — every --dimension/--count call
|
||||
// has a byte-identical --rows/--cols spelling (TestDimFreezeEquivalent pins
|
||||
// this) — and it is the form that reads as if it scoped to one axis when the
|
||||
// backend replaces the whole freeze state.
|
||||
//
|
||||
// dimFreezeLegacyNote returns "" for the modern form. It takes a flagView
|
||||
// rather than a RuntimeContext so +batch-update can render the identical
|
||||
// wording for a sub-op (see batchLegacyDimFreezeNotes).
|
||||
func dimFreezeLegacyNote(runtime flagView) string {
|
||||
if !runtime.Changed("dimension") && !runtime.Changed("count") {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"note: --dimension/--count is superseded by --rows/--cols, which state both axes at once; this call is equivalent to %s",
|
||||
dimFreezeEquivalent(runtime))
|
||||
}
|
||||
|
||||
// dimFreezeEquivalent renders the --rows/--cols spelling of a legacy
|
||||
// --dimension/--count call, so the deprecation note carries the exact
|
||||
// replacement instead of a generic pointer.
|
||||
func dimFreezeEquivalent(runtime flagView) string {
|
||||
rows, cols, _ := dimFreezeAxes(runtime)
|
||||
return dimFreezeSpelling(rows, cols)
|
||||
}
|
||||
|
||||
// dimFreezeAxes maps either request form onto the (rows, cols) freeze state it
|
||||
// asks for. Pure mapping, no validation — dimFreezeInput validates first and
|
||||
// then calls this, so the request body, the deprecation note and the batch
|
||||
// collision note can never disagree about what a call means. ok is false when
|
||||
// the flags name no state at all, or when the legacy pair is half-given
|
||||
// (--count without --dimension); dimFreezeInput reports both.
|
||||
func dimFreezeAxes(runtime flagView) (rows, cols int, ok bool) {
|
||||
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
|
||||
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
|
||||
switch {
|
||||
case axisForm && !pairForm:
|
||||
return runtime.Int("rows"), runtime.Int("cols"), true
|
||||
case pairForm && !axisForm:
|
||||
if !runtime.Changed("dimension") || !runtime.Changed("count") {
|
||||
return 0, 0, false
|
||||
}
|
||||
// A zero count clears BOTH axes — it is the bare unfreeze operation,
|
||||
// which carries no dimension.
|
||||
if count := runtime.Int("count"); count > 0 {
|
||||
if runtime.Str("dimension") == "row" {
|
||||
return count, 0, true
|
||||
}
|
||||
return 0, count, true
|
||||
}
|
||||
return 0, 0, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// dimFreezeSpelling renders a freeze state as the --rows/--cols flags that
|
||||
// produce it. Single source of the replacement wording, shared by the
|
||||
// deprecation note and the batch collision note.
|
||||
func dimFreezeSpelling(rows, cols int) string {
|
||||
switch {
|
||||
case rows > 0 && cols > 0:
|
||||
return fmt.Sprintf("--rows %d --cols %d", rows, cols)
|
||||
case rows > 0:
|
||||
return fmt.Sprintf("--rows %d", rows)
|
||||
case cols > 0:
|
||||
return fmt.Sprintf("--cols %d", cols)
|
||||
}
|
||||
return "--rows 0 --cols 0"
|
||||
}
|
||||
|
||||
// dimFreezeInput builds the freeze body for both the standalone shortcut and
|
||||
// the +batch-update sub-op, so the two stay byte-identical (see
|
||||
// TestBatchOp_BodyMatchesStandalone).
|
||||
//
|
||||
// Two request forms, deliberately not mixable:
|
||||
//
|
||||
// - --rows / --cols state the complete target state in ONE operation. This
|
||||
// is the only form that can hold both axes, because freeze is full-state
|
||||
// replacement server-side (verified 07-31 live: freeze rows=1 then
|
||||
// columns=2 in two calls ends at 0 rows / 2 columns — the second call
|
||||
// drops the first axis). It is also the only form usable inside
|
||||
// +batch-update, whose sub-ops are a static array that cannot read the
|
||||
// current state to preserve an axis.
|
||||
// - --dimension + --count is the original single-axis form, kept for
|
||||
// compatibility. It necessarily unfreezes the axis it does not name.
|
||||
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("dimension") {
|
||||
return nil, sheetsValidationForFlag("dimension", "--dimension is required")
|
||||
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
|
||||
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
|
||||
switch {
|
||||
case pairForm && axisForm:
|
||||
return nil, sheetsValidationForFlag("rows",
|
||||
"give either --rows/--cols or --dimension/--count, not both — they are two ways to say the same thing; --rows/--cols is the one that can hold both axes at once")
|
||||
case !pairForm && !axisForm:
|
||||
// Prescribes only --rows/--cols: --dimension/--count is retired
|
||||
// (DEPRECATED(phase-2)) and steering a caller into it here would earn
|
||||
// them a deprecation note on the very next call.
|
||||
return nil, sheetsValidationForFlag("rows",
|
||||
"nothing to freeze: pass --rows N and/or --cols N — e.g. --rows 1 holds the header row, --rows 1 --cols 2 holds it plus the first 2 columns, --rows 0 --cols 0 unfreezes everything")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
|
||||
}
|
||||
dim := runtime.Str("dimension")
|
||||
count := runtime.Int("count")
|
||||
op := "freeze"
|
||||
if count == 0 {
|
||||
op = "unfreeze"
|
||||
}
|
||||
input := map[string]interface{}{"excel_id": token, "operation": op}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op == "freeze" {
|
||||
if dim == "row" {
|
||||
input["freeze_rows"] = count
|
||||
} else {
|
||||
input["freeze_columns"] = count
|
||||
|
||||
if axisForm {
|
||||
for _, name := range []string{"rows", "cols"} {
|
||||
if runtime.Changed(name) && runtime.Int(name) < 0 {
|
||||
return nil, sheetsValidationForFlag(name, "--%s must be >= 0 (0 leaves that axis unfrozen)", name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !runtime.Changed("dimension") {
|
||||
return nil, sheetsValidationForFlag("dimension", "--dimension is required alongside --count (or use --rows/--cols to set both axes at once)")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, sheetsValidationForFlag("count", "--count is required alongside --dimension (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
|
||||
}
|
||||
}
|
||||
// Validation done; the flags-to-state mapping is dimFreezeAxes', shared with
|
||||
// the deprecation and collision notes so the three cannot disagree.
|
||||
rows, cols, _ := dimFreezeAxes(runtime)
|
||||
|
||||
// An all-zero target is the bare "unfreeze" operation, which carries no
|
||||
// dimension and clears everything — the same request the old --count 0
|
||||
// always sent.
|
||||
input := map[string]interface{}{"excel_id": token, "operation": "unfreeze"}
|
||||
if rows > 0 || cols > 0 {
|
||||
input["operation"] = "freeze"
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if rows > 0 {
|
||||
input["freeze_rows"] = rows
|
||||
}
|
||||
if cols > 0 {
|
||||
input["freeze_columns"] = cols
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -557,6 +877,23 @@ func columnIndexToLetter(idx int) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
|
||||
// "C" → "B"), preserving row/column form. ok is false when s is the first
|
||||
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
|
||||
// position. Callers validate via parseA1Position first, so in practice ok is
|
||||
// false only at the first row/column.
|
||||
func a1PositionBefore(s string) (pos string, ok bool) {
|
||||
dimension, idx, err := parseA1Position(s)
|
||||
if err != nil || idx == 0 {
|
||||
return "", false
|
||||
}
|
||||
if dimension == "row" {
|
||||
// idx is 0-based; the 1-based number one row earlier is idx itself.
|
||||
return strconv.Itoa(idx), true
|
||||
}
|
||||
return columnIndexToLetter(idx - 1), true
|
||||
}
|
||||
|
||||
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
|
||||
//
|
||||
// Moves a contiguous block of rows or columns to a new index in the same
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -48,6 +50,8 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// --inherit-style before copies the preceding row: anchor row 5 and
|
||||
// insert after it (side=after), so the blank still lands before row 6.
|
||||
name: "+dim-insert row position=6 count=3 inherit-before",
|
||||
sc: DimInsert,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
|
||||
@@ -56,9 +60,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": "6",
|
||||
"position": "5",
|
||||
"count": float64(3),
|
||||
"side": "before",
|
||||
"side": "after",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -133,6 +137,47 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole point of --rows/--cols: both axes in ONE operation.
|
||||
// Two single-axis calls would leave only the last axis frozen,
|
||||
// because freeze is full-state replacement server-side.
|
||||
name: "+dim-freeze --rows 1 --cols 2 → one combined op",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "1", "--cols", "2"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "freeze",
|
||||
"sheet_id": testSheetID,
|
||||
"freeze_rows": float64(1),
|
||||
"freeze_columns": float64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Stating the survivor is how you unfreeze one axis and keep the
|
||||
// other; a zero axis is simply omitted from the body.
|
||||
name: "+dim-freeze --rows 0 --cols 2 → columns only",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "2"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "freeze",
|
||||
"sheet_id": testSheetID,
|
||||
"freeze_columns": float64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-freeze --rows 0 --cols 0 → unfreeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "0"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "unfreeze",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-group row 1:5 fold",
|
||||
sc: DimGroup,
|
||||
@@ -169,6 +214,127 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDimInsertInheritStyleSideMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
position string
|
||||
inherit string
|
||||
wantPosition string
|
||||
wantSide string
|
||||
wantSideSet bool
|
||||
}{
|
||||
{
|
||||
name: "after copies the following style with a plain before-insert, position unchanged",
|
||||
position: "D",
|
||||
inherit: "after",
|
||||
wantPosition: "D",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before anchors one column earlier (side=after) to copy the preceding style",
|
||||
position: "D",
|
||||
inherit: "before",
|
||||
wantPosition: "C",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before on a row anchors one row earlier",
|
||||
position: "6",
|
||||
inherit: "before",
|
||||
wantPosition: "5",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before at the first column falls back to a plain before-insert",
|
||||
position: "A",
|
||||
inherit: "before",
|
||||
wantPosition: "A",
|
||||
wantSideSet: false,
|
||||
},
|
||||
{
|
||||
name: "after at the first column still works (before-insert anchors the following)",
|
||||
position: "A",
|
||||
inherit: "after",
|
||||
wantPosition: "A",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
// The flag documents `after` as its default, so omitting it must
|
||||
// build the same body rather than leaving `side` to the backend's
|
||||
// own default — see TestDimInsertOmittedMatchesAfter.
|
||||
name: "default (flag omitted) sends the same side as --inherit-style after",
|
||||
position: "D",
|
||||
wantPosition: "D",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", tc.position, "--count", "1"}
|
||||
if tc.inherit != "" {
|
||||
args = append(args, "--inherit-style", tc.inherit)
|
||||
}
|
||||
body := parseDryRunBody(t, DimInsert, args)
|
||||
got := decodeToolInput(t, body, "modify_sheet_structure")
|
||||
assertInputEquals(t, got, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": tc.wantPosition,
|
||||
"count": float64(1),
|
||||
})
|
||||
|
||||
gv, ok := got["side"]
|
||||
if ok != tc.wantSideSet {
|
||||
t.Fatalf("side presence = %v, want %v (input=%#v)", ok, tc.wantSideSet, got)
|
||||
}
|
||||
if ok && gv != tc.wantSide {
|
||||
t.Fatalf("side = %v, want %q", gv, tc.wantSide)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimInsertOmittedMatchesAfter pins the contract --inherit-style's flag
|
||||
// description states: omitting it is the same call as passing `after`.
|
||||
//
|
||||
// Verified live 07-31 rather than assumed: on a sheet with row2 red and row3
|
||||
// blue, inserting at --position 3 places the blank at row 3 in all four
|
||||
// spellings (omitted with no `side` field at all, omitted, `after`, `before`),
|
||||
// and the blank inherits the FOLLOWING row's blue under omitted/`after` and the
|
||||
// PRECEDING row's red under `before`. So the backend's own default for `side`
|
||||
// is "before" and the pre-existing behaviour was already correct; the CLI sends
|
||||
// the field explicitly only so the documented default stops depending on an
|
||||
// undocumented server-side one. This test locks the two bodies together, byte
|
||||
// for byte, so that stays true.
|
||||
func TestDimInsertOmittedMatchesAfter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, position := range []string{"1", "3", "A", "D"} {
|
||||
t.Run("position "+position, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", position, "--count", "1"}
|
||||
omitted := decodeToolInput(t, parseDryRunBody(t, DimInsert, base), "modify_sheet_structure")
|
||||
explicit := decodeToolInput(t,
|
||||
parseDryRunBody(t, DimInsert, append(append([]string{}, base...), "--inherit-style", "after")),
|
||||
"modify_sheet_structure")
|
||||
if !reflect.DeepEqual(omitted, explicit) {
|
||||
t.Fatalf("omitted --inherit-style built %#v, --inherit-style after built %#v; they must be identical", omitted, explicit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimRange_Validation covers the A1 range parser's edge cases routed
|
||||
// through +dim-hide (any --range shortcut works; we just need to exercise
|
||||
// the validator).
|
||||
@@ -204,6 +370,233 @@ func TestDimRange_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimFreezeEquivalent pins the replacement spelling printed by the
|
||||
// phase-1 deprecation note: it must be the exact --rows/--cols call the user
|
||||
// should switch to, not a generic pointer. Each pairing is also asserted for
|
||||
// body equality, which is what makes the legacy form strictly redundant.
|
||||
func TestDimFreezeEquivalent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
dimension string
|
||||
count int
|
||||
want string
|
||||
}{
|
||||
{"row", 2, "--rows 2"},
|
||||
{"column", 3, "--cols 3"},
|
||||
{"row", 0, "--rows 0 --cols 0"},
|
||||
{"column", 0, "--rows 0 --cols 0"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
legacy := newMapFlagViewForCommand("+dim-freeze", map[string]interface{}{
|
||||
"dimension": tt.dimension, "count": tt.count,
|
||||
})
|
||||
if got := dimFreezeEquivalent(legacy); got != tt.want {
|
||||
t.Fatalf("dimFreezeEquivalent = %q, want %q", got, tt.want)
|
||||
}
|
||||
// The advertised replacement must produce the identical body.
|
||||
modern := map[string]interface{}{}
|
||||
if tt.count > 0 {
|
||||
if tt.dimension == "row" {
|
||||
modern["rows"] = tt.count
|
||||
} else {
|
||||
modern["cols"] = tt.count
|
||||
}
|
||||
} else {
|
||||
modern["rows"], modern["cols"] = 0, 0
|
||||
}
|
||||
legacyInput, err := dimFreezeInput(legacy, testToken, testSheetID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy form: %v", err)
|
||||
}
|
||||
modernInput, err := dimFreezeInput(newMapFlagViewForCommand("+dim-freeze", modern), testToken, testSheetID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("modern form: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(legacyInput, modernInput) {
|
||||
t.Fatalf("bodies diverge:\n legacy = %v\n modern = %v", legacyInput, modernInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetiredEnumValueMatchesOmitted pins the back-compat contract for enum
|
||||
// values this CLI retired: --inherit-style none was valid AND the default
|
||||
// before the side mapping was corrected, so rejecting it would break existing
|
||||
// scripts and any agent carrying older docs. It must behave exactly as if the
|
||||
// flag were omitted — on the standalone path and inside +batch-update alike,
|
||||
// since +dim-insert is batchable and a divergence there would be invisible.
|
||||
func TestRetiredEnumValueMatchesOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "3", "--count", "1"}
|
||||
// Must be the registry copy: the retired-value rewrite lives in the
|
||||
// PostMount ergonomics layer, which Shortcuts() installs and the raw
|
||||
// exported var does not carry.
|
||||
dimInsert := shortcutFromRegistry(t, "+dim-insert")
|
||||
|
||||
omitted := parseDryRunBody(t, dimInsert, base)
|
||||
for _, val := range []string{"none", "NONE", "None"} {
|
||||
got := parseDryRunBody(t, dimInsert, append(append([]string{}, base...), "--inherit-style", val))
|
||||
if !reflect.DeepEqual(got, omitted) {
|
||||
t.Fatalf("--inherit-style %s body = %v, want the omitted body %v", val, got, omitted)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("still rejects a genuinely invalid value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, shortcutFromRegistry(t, "+dim-insert"),
|
||||
append(append([]string{}, base...), "--inherit-style", "banana", "--dry-run"))
|
||||
requireValidation(t, err, `invalid value "banana"`)
|
||||
})
|
||||
|
||||
t.Run("reports as absent on both paths, not just empty", func(t *testing.T) {
|
||||
// The two paths clear the value differently (cobra Set vs deleting the
|
||||
// raw key), so Changed() is the part that can silently diverge: a flag
|
||||
// whose logic reads Changed() rather than the value would then behave
|
||||
// differently standalone than inside +batch-update.
|
||||
t.Parallel()
|
||||
parent, _, _, _ := newTestRig(t, shortcutFromRegistry(t, "+dim-insert"))
|
||||
parent.SetArgs(append([]string{"+dim-insert"},
|
||||
append(append([]string{}, base...), "--inherit-style", "none", "--dry-run")...))
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
cmd, _, err := parent.Find([]string{"+dim-insert"})
|
||||
if err != nil {
|
||||
t.Fatalf("find command: %v", err)
|
||||
}
|
||||
if cmd.Flags().Changed("inherit-style") {
|
||||
t.Error("standalone: Changed() must report the retired value as absent")
|
||||
}
|
||||
|
||||
fv := newMapFlagViewForCommand("+dim-insert", map[string]interface{}{
|
||||
"position": 3, "count": 1, "inherit-style": "none",
|
||||
})
|
||||
if err := fv.normalizeAndValidateEnums(); err != nil {
|
||||
t.Fatalf("batch enum pass: %v", err)
|
||||
}
|
||||
if fv.Changed("inherit-style") {
|
||||
t.Error("batch: Changed() must report the retired value as absent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch sub-op treats it the same", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sub := func(extra string) map[string]interface{} {
|
||||
ops := `[{"shortcut":"+dim-insert","input":{"sheet-id":"sh1","position":3,"count":1` + extra + `}}]`
|
||||
body := parseDryRunBody(t, shortcutFromRegistry(t, "+batch-update"), []string{"--url", testURL, "--operations", ops})
|
||||
input, _ := body["input"].(string)
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(input), &decoded); err != nil {
|
||||
t.Fatalf("decode batch input: %v (raw=%s)", err, input)
|
||||
}
|
||||
opsOut, _ := decoded["operations"].([]interface{})
|
||||
if len(opsOut) != 1 {
|
||||
t.Fatalf("want 1 translated op, got %v", decoded["operations"])
|
||||
}
|
||||
first, _ := opsOut[0].(map[string]interface{})
|
||||
return first
|
||||
}
|
||||
if got, want := sub(`,"inherit-style":"none"`), sub(""); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("batch sub-op with none = %v, want the omitted form %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimFreezeLegacyNote pins WHERE the phase-1 deprecation steer appears.
|
||||
// The note used to fire only from the standalone Execute, which missed the two
|
||||
// paths that matter most: --dry-run (how agents preview before committing to a
|
||||
// spelling) and +batch-update (where two per-axis sub-ops both report success
|
||||
// while only the last axis stays frozen).
|
||||
func TestDimFreezeLegacyNote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
legacy := []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"}
|
||||
modern := []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "2"}
|
||||
|
||||
t.Run("standalone dry-run carries the note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, DimFreeze, legacy)
|
||||
if !strings.Contains(warning, "equivalent to --rows 2") {
|
||||
t.Fatalf("dry-run warning = %q, want the exact replacement", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("modern form stays silent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if w := dryRunWarning(t, DimFreeze, modern); w != "" {
|
||||
t.Fatalf("modern form must not warn, got %q", w)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch sub-op carries the note with its index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := []string{"--url", testURL, "--operations",
|
||||
`[{"shortcut":"+cells-clear","input":{"sheet-id":"sh1","range":"A1:B2"}},` +
|
||||
`{"shortcut":"+dim-freeze","input":{"sheet-id":"sh1","dimension":"column","count":3}}]`}
|
||||
warning := dryRunWarning(t, BatchUpdate, args)
|
||||
if !strings.Contains(warning, "operations[1] (+dim-freeze)") || !strings.Contains(warning, "equivalent to --cols 3") {
|
||||
t.Fatalf("batch warning = %q, want the indexed note with the replacement", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch with only modern sub-ops stays silent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := []string{"--url", testURL, "--operations",
|
||||
`[{"shortcut":"+dim-freeze","input":{"sheet-id":"sh1","rows":1,"cols":2}}]`}
|
||||
if w := dryRunWarning(t, BatchUpdate, args); w != "" {
|
||||
t.Fatalf("modern sub-op must not warn, got %q", w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimFreeze_FormValidation pins the two request forms as mutually
|
||||
// exclusive, and pins that neither-form is a prescriptive error rather than a
|
||||
// silent no-op.
|
||||
func TestDimFreeze_FormValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "forms cannot be mixed",
|
||||
args: []string{"--rows", "1", "--dimension", "row", "--count", "1"},
|
||||
want: "not both",
|
||||
},
|
||||
{
|
||||
name: "neither form given",
|
||||
args: []string{},
|
||||
want: "nothing to freeze",
|
||||
},
|
||||
{
|
||||
name: "negative rows",
|
||||
args: []string{"--rows", "-1"},
|
||||
want: "--rows must be >= 0",
|
||||
},
|
||||
{
|
||||
name: "count without dimension",
|
||||
args: []string{"--count", "2"},
|
||||
want: "--dimension is required alongside --count",
|
||||
},
|
||||
{
|
||||
name: "dimension without count",
|
||||
args: []string{"--dimension", "row"},
|
||||
want: "--count is required alongside --dimension",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := append([]string{"--url", testURL, "--sheet-id", testSheetID, "--dry-run"}, tt.args...)
|
||||
_, _, err := runShortcutCapturingErr(t, DimFreeze, args)
|
||||
requireValidation(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimMove_DryRun verifies the native v3 move_dimension payload shape.
|
||||
// CLI's --source-range "1:3" (1-based inclusive) is parsed into
|
||||
// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is
|
||||
|
||||
296
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
296
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── +styles-put ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Declarative visual spec for EXISTING spreadsheets. Eval attribution
|
||||
// showed ~73% of real +batch-update calls were pure formatting finishers
|
||||
// (style stamps + merges + resizes + freeze) hand-assembled as imperative
|
||||
// operations arrays — the top error surface. +styles-put replaces that
|
||||
// with the {styles:[...]} protocol already shared by +workbook-create /
|
||||
// +table-put --styles (identical vocabulary, parsed by the same
|
||||
// parseWorkbookCreateStyleItem), applied to a live workbook and expanded
|
||||
// client-side into ONE atomic batch_update.
|
||||
//
|
||||
// Per-sheet expansion order (server behavior verified live: style stamps
|
||||
// over merged regions are allowed — the top-left-only restriction applies
|
||||
// to value writes, not styles):
|
||||
//
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze
|
||||
var StylesPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+styles-put",
|
||||
Description: "Apply one declarative visual spec (styles/merges/row-col sizes/freeze) to existing sheets in one batch request (fail-fast, no rollback).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+styles-put"),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +styles-put --url <URL> --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:F1","font_weight":"bold"}],"freeze":{"rows":1}}]}'`,
|
||||
"Same --styles vocabulary as +workbook-create / +table-put; one item per target sheet, name = the real sheet name.",
|
||||
"Style stamps are safe to re-run; the whole spec goes out as one batch request — fail-fast, and applied sub-ops are NOT rolled back.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stylesPutOperations(runtime, token)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
ops, _ := stylesPutOperations(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := stylesPutOperations(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// stylesPutOperations parses --styles ({styles:[...]}, one item per target
|
||||
// sheet) and expands it into the MCP batch_update operations array. Reuses
|
||||
// the shared workbook-create style item parser, so field validation, alias
|
||||
// normalization (border "all" shorthand, style vocabulary) and the
|
||||
// aggregate-all-issues error shape are identical across the three --styles
|
||||
// carriers.
|
||||
func stylesPutOperations(runtime flagView, token string) ([]interface{}, error) {
|
||||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles is required")
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := parseWorkbookCreateStylesItems(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles.styles must be a non-empty array (one item per target sheet)")
|
||||
}
|
||||
var probs []error
|
||||
type sheetSpec struct {
|
||||
name string
|
||||
payload *workbookCreateStylePayload
|
||||
}
|
||||
specs := make([]sheetSpec, 0, len(items))
|
||||
seenName := map[string]bool{}
|
||||
for i, item := range items {
|
||||
path := fmt.Sprintf("--styles.styles[%d]", i)
|
||||
name, _ := item["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name is required (the real sheet name; check +workbook-info)", path))
|
||||
continue
|
||||
}
|
||||
if seenName[name] {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name %q appears twice; merge the two items", path, name))
|
||||
continue
|
||||
}
|
||||
seenName[name] = true
|
||||
payload, itemProbs := parseWorkbookCreateStyleItem(item, path)
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
specs = append(specs, sheetSpec{name: name, payload: payload})
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops := make([]interface{}, 0, len(specs)*4)
|
||||
var totalCells int64
|
||||
appendVisual := func(name string, op workbookCreateStyleOp) {
|
||||
input, toolName := workbookCreateVisualOpInput(token, "", name, op)
|
||||
if toolName == "" {
|
||||
return
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{"tool_name": toolName, "input": input})
|
||||
}
|
||||
for _, spec := range specs {
|
||||
// merges first so subsequent style stamps see the final grid.
|
||||
for _, m := range spec.payload.CellMerges {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "cell_merge", Range: m.Range, MergeType: m.MergeType})
|
||||
}
|
||||
for _, cs := range coalesceStyleStamps(spec.payload.CellStyles) {
|
||||
rows, cols, err := rangeDimensions(cs.Range)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("styles", "cell_styles range %q: %v", cs.Range, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("styles", cs.Range, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget("styles", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": spec.name,
|
||||
"range": stripSheetPrefix(cs.Range),
|
||||
"cells": fillCellsMatrix(rows, cols, cs.Style),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, rs := range spec.payload.RowSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "row_size", Range: rs.Range, ResizeType: rs.ResizeType, Size: rs.Size})
|
||||
}
|
||||
for _, csz := range spec.payload.ColSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "col_size", Range: csz.Range, ResizeType: csz.ResizeType, Size: csz.Size})
|
||||
}
|
||||
if f := spec.payload.Freeze; f != nil {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze", FreezeRows: f.Rows, FreezeCols: f.Cols})
|
||||
}
|
||||
}
|
||||
if len(ops) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("styles",
|
||||
"--styles expands to %d operations even after merging adjacent same-style ranges, over the %d cap; split the spec into several +styles-put calls — and for alternating-row banding or value-dependent coloring use +cond-format-create instead of per-row stamps",
|
||||
len(ops), maxBatchOperations)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// coalesceStyleStamps merges cell_styles entries that carry the IDENTICAL
|
||||
// style into larger rectangles: same column span + contiguous/overlapping
|
||||
// rows fuse vertically, same row span + contiguous columns fuse
|
||||
// horizontally, iterated to a fixpoint. Models routinely emit one entry per
|
||||
// row (07-21 rerun: specs expanding to 184/203/861 operations against the
|
||||
// 100-op cap); a declarative spec describes intent, so execution shape is
|
||||
// the CLI's to optimize. Entries with unparsable ranges pass through
|
||||
// untouched (the per-op validation reports them with proper context).
|
||||
func coalesceStyleStamps(ops []workbookCreateCellStyleOp) []workbookCreateCellStyleOp {
|
||||
if len(ops) < 2 {
|
||||
return ops
|
||||
}
|
||||
type rect struct{ c1, r1, c2, r2 int }
|
||||
type entry struct {
|
||||
op workbookCreateCellStyleOp
|
||||
rc rect
|
||||
key string
|
||||
parsed bool
|
||||
alive bool
|
||||
}
|
||||
entries := make([]entry, len(ops))
|
||||
for i, op := range ops {
|
||||
e := entry{op: op, alive: true}
|
||||
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
key, jerr := json.Marshal(op.Style) // map keys marshal sorted → canonical
|
||||
if err == nil && jerr == nil {
|
||||
e.rc, e.key, e.parsed = rect{c1, r1, c2, r2}, string(key), true
|
||||
}
|
||||
entries[i] = e
|
||||
}
|
||||
intersects := func(a, b rect) bool {
|
||||
return a.c1 <= b.c2 && b.c1 <= a.c2 && a.r1 <= b.r2 && b.r1 <= a.r2
|
||||
}
|
||||
// union returns the rectangle covering exactly a ∪ b, and whether the two
|
||||
// are mergeable at all: only same-column-span rows or same-row-span columns
|
||||
// that touch or overlap, so the union introduces no cell outside a ∪ b.
|
||||
union := func(a, b rect) (rect, bool) {
|
||||
switch {
|
||||
case a.c1 == b.c1 && a.c2 == b.c2 && b.r1 <= a.r2+1 && a.r1 <= b.r2+1:
|
||||
return rect{a.c1, min(a.r1, b.r1), a.c2, max(a.r2, b.r2)}, true
|
||||
case a.r1 == b.r1 && a.r2 == b.r2 && b.c1 <= a.c2+1 && a.c1 <= b.c2+1:
|
||||
return rect{min(a.c1, b.c1), a.r1, max(a.c2, b.c2), a.r2}, true
|
||||
}
|
||||
return rect{}, false
|
||||
}
|
||||
// Merging op j (later) into op i (earlier) moves j's write forward to i's
|
||||
// position, so it is only sound when nothing between them touches j's
|
||||
// cells — otherwise that intermediate op, which j used to overwrite, would
|
||||
// now land last and win. Style writes are field-wise last-write-wins
|
||||
// (mergeWorkbookCreateStyle), so silently reordering same-style stamps
|
||||
// around a differing one changes the final appearance.
|
||||
for i := range entries {
|
||||
if !entries[i].alive || !entries[i].parsed {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if !entries[j].alive || !entries[j].parsed || entries[j].key != entries[i].key {
|
||||
continue
|
||||
}
|
||||
merged, ok := union(entries[i].rc, entries[j].rc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
safe := true
|
||||
for k := i + 1; k < j && safe; k++ {
|
||||
if !entries[k].alive {
|
||||
continue
|
||||
}
|
||||
// An unparsable range has unknown coverage: assume it collides.
|
||||
if !entries[k].parsed || intersects(entries[k].rc, entries[j].rc) {
|
||||
safe = false
|
||||
}
|
||||
}
|
||||
if !safe {
|
||||
continue
|
||||
}
|
||||
entries[i].rc = merged
|
||||
entries[j].alive = false
|
||||
j = i // rescan: the grown rectangle may now absorb earlier misses
|
||||
}
|
||||
}
|
||||
out := make([]workbookCreateCellStyleOp, 0, len(ops))
|
||||
for _, e := range entries {
|
||||
if !e.alive {
|
||||
continue
|
||||
}
|
||||
if !e.parsed {
|
||||
out = append(out, e.op)
|
||||
continue
|
||||
}
|
||||
out = append(out, workbookCreateCellStyleOp{
|
||||
Range: fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(e.rc.c1), e.rc.r1+1,
|
||||
columnIndexToLetter(e.rc.c2), e.rc.r2+1),
|
||||
Style: e.op.Style,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripSheetPrefix drops an optional "Sheet!"-style prefix from an A1 range:
|
||||
// the target sheet is already carried by the spec item's name, and the
|
||||
// batch sub-op input names the sheet separately.
|
||||
func stripSheetPrefix(rangeStr string) string {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
return strings.TrimSpace(rangeStr[idx+1:])
|
||||
}
|
||||
return strings.TrimSpace(rangeStr)
|
||||
}
|
||||
479
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
479
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stylesPutView(spec map[string]interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+styles-put", map[string]interface{}{"styles": spec})
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_ExpansionOrder pins the per-sheet expansion:
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze, all inside one
|
||||
// batch_update operations array (server-side order dependence verified live).
|
||||
func TestStylesPutOperations_ExpansionOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Sheet1",
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "A5:A8"}},
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "A1:B1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:B", "type": "pixel", "size": float64(120)}},
|
||||
"freeze": map[string]interface{}{"rows": float64(1), "cols": float64(2)},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantTools := []string{"merge_cells", "set_cell_range", "resize_range", "resize_range", "modify_sheet_structure"}
|
||||
if len(ops) != len(wantTools) {
|
||||
t.Fatalf("got %d ops, want %d", len(ops), len(wantTools))
|
||||
}
|
||||
for i, want := range wantTools {
|
||||
op := ops[i].(map[string]interface{})
|
||||
if op["tool_name"] != want {
|
||||
t.Fatalf("ops[%d].tool_name = %v, want %s", i, op["tool_name"], want)
|
||||
}
|
||||
input := op["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "Sheet1" {
|
||||
t.Fatalf("ops[%d] missing sheet_name: %v", i, input)
|
||||
}
|
||||
if input["excel_id"] != testToken {
|
||||
t.Fatalf("ops[%d] missing excel_id", i)
|
||||
}
|
||||
}
|
||||
// The style stamp carries a cells matrix matching the range (1×2).
|
||||
stamp := ops[1].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := stamp["cells"].([][]interface{})
|
||||
if len(cells) != 1 || len(cells[0]) != 2 {
|
||||
t.Fatalf("style stamp matrix = %dx%d, want 1x2", len(cells), len(cells[0]))
|
||||
}
|
||||
// Freeze rows and columns are combined into one operation because freeze is
|
||||
// full-state replacement server-side (verified 07-31 live): two per-axis
|
||||
// calls leave only the last axis frozen.
|
||||
freeze := ops[4].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if freeze["operation"] != "freeze" || freeze["freeze_rows"] != 1 || freeze["freeze_columns"] != 2 {
|
||||
t.Fatalf("freeze op = %v", freeze)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_Validation pins the aggregate error shape and the
|
||||
// section/name requirements.
|
||||
func TestStylesPutOperations_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing name and empty item aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}}},
|
||||
map[string]interface{}{"name": "S2"},
|
||||
},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, "name is required")
|
||||
if !strings.Contains(ve.Message, "at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze") {
|
||||
t.Fatalf("message %q missing empty-item issue", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate sheet name rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}
|
||||
item2 := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(2)}}
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{item, item2},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "appears twice")
|
||||
})
|
||||
|
||||
t.Run("freeze-only item is valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("ops=%d err=%v", len(ops), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-zero freeze rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(0)}}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "at least one dimension")
|
||||
})
|
||||
|
||||
t.Run("range prefixed with another sheet rejected", func(t *testing.T) {
|
||||
// Silently stripping "Detail!" would retarget the styles onto Summary.
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Detail!A1:D1", "font_weight": "bold"}},
|
||||
}},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, `names sheet "Detail" but the item targets "Summary"`)
|
||||
if !strings.Contains(ve.Message, "cell_styles") {
|
||||
t.Fatalf("message %q should locate the offending section", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range prefixed with the item's own sheet passes and strips for every visual op", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "'Summary'!A1:D1", "font_weight": "bold"}},
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "Summary!A2:B2"}, "'Summary'!C2:D2"},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Summary!2:3", "type": "pixel", "size": float64(32)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "'Summary'!A:C", "type": "pixel", "size": float64(120)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("matching prefix must stay accepted: %v", err)
|
||||
}
|
||||
gotRanges := []string{}
|
||||
for _, raw := range ops {
|
||||
input := raw.(map[string]interface{})["input"].(map[string]interface{})
|
||||
if rng, _ := input["range"].(string); rng != "" {
|
||||
gotRanges = append(gotRanges, rng)
|
||||
}
|
||||
}
|
||||
want := []string{"A2:B2", "C2:D2", "A1:D1", "2:3", "A:C"}
|
||||
if len(gotRanges) != len(want) {
|
||||
t.Fatalf("ranges = %v, want %v", gotRanges, want)
|
||||
}
|
||||
for i := range want {
|
||||
if gotRanges[i] != want[i] {
|
||||
t.Fatalf("ranges = %v, want %v", gotRanges, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown item key rejected with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}},
|
||||
"freezee": map[string]interface{}{"rows": float64(1)},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, `unknown key "freezee" — did you mean "freeze"`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesPayloadVocabularyForgiveness pins the 07-20 rerun fixes: the
|
||||
// payload path (--styles cell_styles objects) accepts the same habitual
|
||||
// vocabulary the flag path already normalized — border family folding, wrap
|
||||
// aliases, and enum VALUE canonicalization (CSS center → Lark middle etc.).
|
||||
func TestStylesPayloadVocabularyForgiveness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stamp := func(styleFields map[string]interface{}) ([]interface{}, error) {
|
||||
item := map[string]interface{}{"range": "A1:B1"}
|
||||
for k, v := range styleFields {
|
||||
item[k] = v
|
||||
}
|
||||
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{item},
|
||||
}},
|
||||
}), testToken)
|
||||
}
|
||||
cellProto := func(t *testing.T, ops []interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
return cells[0][0].(map[string]interface{})
|
||||
}
|
||||
|
||||
t.Run("vertical_alignment center canonicalizes to middle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"vertical_alignment": "center", "font_weight": "BOLD"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["vertical_alignment"] != "middle" || cs["font_weight"] != "bold" {
|
||||
t.Fatalf("cell_styles = %v, want middle/bold", cs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("off-enum value rejected client-side with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"vertical_alignment": "botom"})
|
||||
requireValidation(t, err, `did you mean "bottom"`)
|
||||
})
|
||||
|
||||
t.Run("boolean wrap_text folds to word_wrap auto-wrap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"wrap_text": true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["word_wrap"] != "auto-wrap" {
|
||||
t.Fatalf("word_wrap = %v, want auto-wrap", cs["word_wrap"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("borders object folds into border_styles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"borders": map[string]interface{}{"style": "solid", "color": "#000000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top == nil || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles = %v, want all-sides solid", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flattened border_bottom and border_top_color fold per side", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"border_bottom": map[string]interface{}{"style": "solid"},
|
||||
"border_top_color": "#FF0000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
bottom, _ := bs["bottom"].(map[string]interface{})
|
||||
topSide, _ := bs["top"].(map[string]interface{})
|
||||
if bottom["style"] != "solid" || topSide["color"] != "#FF0000" {
|
||||
t.Fatalf("border_styles = %v", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("border_style thin means thin solid line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"border_style": "thin"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top["weight"] != "thin" || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles.top = %v, want thin solid", top)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fore_color prescribes instead of guessing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"fore_color": "#FF0000"})
|
||||
requireValidation(t, err, "fore_color is ambiguous")
|
||||
})
|
||||
|
||||
t.Run("bare string cell_merges accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_merges": []interface{}{"A5:B6"},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A5:B6" || input["merge_type"] != "all" {
|
||||
t.Fatalf("merge op = %v", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesResizeSizeAliases pins the one-way Excel-vocabulary aliases on
|
||||
// the shared styles resize parser: height in row_sizes / width in col_sizes
|
||||
// resolve to size silently; the wrong dimension's word is a targeted error.
|
||||
func TestStylesResizeSizeAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("height aliases to size in row_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "height": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
block := input["resize_height"].(map[string]interface{})
|
||||
if block["value"] != 36 {
|
||||
t.Fatalf("resize_height = %v, want value 36", block)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width aliases to size in col_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:C", "type": "pixel", "width": float64(120)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong-dimension word is a targeted error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "width": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "does not apply to this array")
|
||||
})
|
||||
|
||||
t.Run("size plus alias together rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36), "height": float64(40)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "either size or height")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimDeleteRangesOps pins the descending-order expansion and the
|
||||
// same-dimension / non-overlap guards.
|
||||
func TestDimDeleteRangesOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := func(ranges ...interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+dim-delete", map[string]interface{}{"ranges": ranges})
|
||||
}
|
||||
|
||||
t.Run("rows execute descending", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := dimDeleteRangesOps(view("5:5", "11:13", "8:8"), testToken, "", "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got []string
|
||||
for _, op := range ops {
|
||||
got = append(got, op.(map[string]interface{})["input"].(map[string]interface{})["range"].(string))
|
||||
}
|
||||
want := []string{"11:13", "8:8", "5:5"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed dimensions rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:5", "C:C"), testToken, "", "S1")
|
||||
requireValidation(t, err, "rows OR columns")
|
||||
})
|
||||
|
||||
t.Run("overlap rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:8", "7:9"), testToken, "", "S1")
|
||||
requireValidation(t, err, "overlap")
|
||||
})
|
||||
|
||||
t.Run("ranges cannot nest inside batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+dim-delete", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"5:5", "8:8"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCoalesceStyleStamps_PreservesLastWriteWins pins the ordering contract of
|
||||
// the stamp optimizer: style writes are field-wise last-write-wins, so two
|
||||
// same-style stamps may only be merged when nothing between them touches the
|
||||
// cells whose write would move earlier. Grouping globally by style content
|
||||
// (the original implementation) turned red → blue → red into red → blue and
|
||||
// silently changed the final color.
|
||||
func TestCoalesceStyleStamps_PreservesLastWriteWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
red := map[string]interface{}{"background_color": "#FF0000"}
|
||||
blue := map[string]interface{}{"background_color": "#0000FF"}
|
||||
stamp := func(rng string, style map[string]interface{}) workbookCreateCellStyleOp {
|
||||
return workbookCreateCellStyleOp{Range: rng, Style: style}
|
||||
}
|
||||
lastStyleFor := func(ops []workbookCreateCellStyleOp, rng string) map[string]interface{} {
|
||||
var out map[string]interface{}
|
||||
for _, op := range ops {
|
||||
if op.Range == rng {
|
||||
out = op.Style
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("same cell red blue red keeps red last", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", red), stamp("A1:A1", blue), stamp("A1:A1", red),
|
||||
})
|
||||
if last := lastStyleFor(got, "A1:A1"); last == nil || last["background_color"] != "#FF0000" {
|
||||
t.Fatalf("final style for A1 = %v, want the trailing red; ops=%+v", last, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("intervening overlapping stamp blocks the merge", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// bold A1:B1, italic on B1, bold B1 again: merging the two bolds would
|
||||
// hoist B1's bold ahead of the italic and lose the italic.
|
||||
bold := map[string]interface{}{"font_weight": "bold"}
|
||||
italic := map[string]interface{}{"font_style": "italic"}
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", bold), stamp("A1:A1", italic), stamp("A1:A1", bold),
|
||||
})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("overlapping intermediate stamp must prevent merging, got %d ops: %+v", len(got), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adjacent same-style runs still coalesce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bold := map[string]interface{}{"font_weight": "bold"}
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", bold), stamp("A2:A2", bold), stamp("A3:A3", bold),
|
||||
})
|
||||
if len(got) != 1 || got[0].Range != "A1:A3" {
|
||||
t.Fatalf("adjacent same-style stamps should merge into A1:A3, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disjoint intermediate stamp does not block the merge", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bold := map[string]interface{}{"font_weight": "bold"}
|
||||
italic := map[string]interface{}{"font_style": "italic"}
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", bold), stamp("Z9:Z9", italic), stamp("A2:A2", bold),
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("disjoint intermediate stamp should still allow merging, got %+v", got)
|
||||
}
|
||||
if got[0].Range != "A1:A2" {
|
||||
t.Fatalf("bold stamps should merge to A1:A2, got %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
|
||||
return tablePutWrite(ctx, runtime, token, payload, styles)
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
|
||||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||||
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
|
||||
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
|
||||
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
|
||||
// error, so the retry needs no --print-schema round trip. Field vocabulary
|
||||
// mirrors tableSheetIn.
|
||||
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
|
||||
|
||||
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
|
||||
// validated payload. UseNumber keeps numeric cells as json.Number so large
|
||||
// integers (order IDs, etc.) survive without precision loss or scientific
|
||||
@@ -259,7 +265,29 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||||
Sheets []tableSheetIn `json:"sheets"`
|
||||
}
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
// Eval traces show two distinct decode failures that each burned
|
||||
// retries: a field with the wrong JSON kind (columns as objects,
|
||||
// dtypes as an array) — fixed by seeing the expected shape once —
|
||||
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
|
||||
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
var ute *json.UnmarshalTypeError
|
||||
if errors.As(err, &ute) {
|
||||
// A mismatch with no field path is the missing envelope: the
|
||||
// payload IS the sub-sheet list, written without the wrapper.
|
||||
// Say that in the message — the Go unmarshal text ("cannot
|
||||
// unmarshal array into Go value of type struct { Sheets …}")
|
||||
// names the internal type, not the fix.
|
||||
if ute.Field == "" {
|
||||
verr = common.ValidationErrorf(
|
||||
`--sheets: top level must be the object {"sheets":[…]}, got a bare JSON %s; wrap the sub-sheet list in a "sheets" key`,
|
||||
ute.Value).WithCause(err)
|
||||
}
|
||||
return nil, verr.WithHint(
|
||||
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
|
||||
tablePutSheetsSkeleton)
|
||||
}
|
||||
return nil, verr.WithHint(
|
||||
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
|
||||
}
|
||||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||||
@@ -1178,6 +1206,12 @@ var TableGet = common.Shortcut{
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token, "ranges": []string{rng},
|
||||
"include_styles": true, "value_render_option": "raw_value",
|
||||
"cell_limit": unboundedReadLimit,
|
||||
}
|
||||
// Execute adds these caps too; echoing them here keeps dry-run and the
|
||||
// real request the same shape, so validating one tells you about the other.
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
sheetSelectorForToolInput(input,
|
||||
strings.TrimSpace(runtime.Str("sheet-id")),
|
||||
@@ -1201,15 +1235,37 @@ var TableGet = common.Shortcut{
|
||||
noHeader := runtime.Bool("no-header")
|
||||
userRange := strings.TrimSpace(runtime.Str("range"))
|
||||
sheets := make([]interface{}, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader)
|
||||
// The char cap is a memory guard, so it must bound the WHOLE read, not
|
||||
// each sheet independently: a 30-sheet workbook would otherwise be
|
||||
// allowed 30× the cap. Track what previous sheets consumed and hand the
|
||||
// remainder to the next one; when it runs out, stop and name the sheets
|
||||
// left unread instead of silently returning a short workbook.
|
||||
budget := maxCharsBudget(runtime)
|
||||
var unread []string
|
||||
for i, t := range targets {
|
||||
remaining := 0
|
||||
if budget > 0 {
|
||||
remaining = budget - consumedChars(sheets)
|
||||
if remaining <= 0 {
|
||||
for _, rest := range targets[i:] {
|
||||
unread = append(unread, rest.name)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader, remaining)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets = append(sheets, spec)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||||
return nil
|
||||
payload := map[string]interface{}{"sheets": sheets}
|
||||
if len(unread) > 0 {
|
||||
payload["truncated"] = true
|
||||
payload["unread_sheets"] = unread
|
||||
payload["truncation_warning"] = fmt.Sprintf("the %d-char read budget was exhausted before %d sheet(s) were read (%s); re-run per sheet with --sheet-name, or raise --max-chars", budget, len(unread), strings.Join(unread, ", "))
|
||||
}
|
||||
return emitReadResult(runtime, payload)
|
||||
},
|
||||
Tips: []string{
|
||||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||||
@@ -1326,7 +1382,7 @@ func tableGetSheetMeta(r interface{}) (id, name string, rowCount, colCount int)
|
||||
// a single `astype()` call covers every column); `formats` is emitted only for
|
||||
// columns whose source cells carry a non-empty number_format, since `astype`
|
||||
// ignores it and we'd rather not pollute the output.
|
||||
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool) (map[string]interface{}, error) {
|
||||
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool, charBudget int) (map[string]interface{}, error) {
|
||||
emptySpec := func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": t.name,
|
||||
@@ -1354,14 +1410,35 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
"value_render_option": "raw_value",
|
||||
"cell_limit": unboundedReadLimit,
|
||||
}
|
||||
// --max-chars binds the char budget (default 500000); --output-path raises
|
||||
// it to the bounded offload default. Without this the tool applied its own
|
||||
// ~50000 default and silently dropped rows past it with no signal in the
|
||||
// +table-get output. charBudget > 0 caps this sheet by what the whole-
|
||||
// workbook read has left, so a multi-sheet workbook cannot consume the
|
||||
// per-sheet cap N times over.
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
if charBudget > 0 && charBudget < n {
|
||||
n = charBudget
|
||||
}
|
||||
input["max_chars"] = n
|
||||
}
|
||||
sheetSelectorForToolInput(input, t.id, t.name)
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
truncated := cellRangesTruncated(out)
|
||||
grid := extractCellGrid(out)
|
||||
if len(grid) == 0 {
|
||||
return emptySpec(), nil
|
||||
// An empty grid can itself be the result of clipping (the cap was spent
|
||||
// before any row came back), so the truncation flag must survive here —
|
||||
// dropping it reports a partial read as a complete empty sheet.
|
||||
spec := emptySpec()
|
||||
if truncated {
|
||||
spec["truncated"] = true
|
||||
spec["truncation_warning"] = "the read hit the char cap before any row was returned for this sheet; raise --max-chars or read a narrower --range"
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
var headerRow []map[string]interface{}
|
||||
@@ -1433,9 +1510,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if len(formats) > 0 {
|
||||
spec["formats"] = formats
|
||||
}
|
||||
// The tool clipped the read at max_chars: rows past the cap are missing from
|
||||
// data. Surface it so the caller doesn't mistake a partial read for the whole
|
||||
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
|
||||
if truncated {
|
||||
spec["truncated"] = true
|
||||
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the sheet to a file under the much larger offload cap. Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
|
||||
// max_chars — either the top-level has_more flag or the first range's truncated
|
||||
// flag. Used by +table-get, whose spec output otherwise drops both signals.
|
||||
func cellRangesTruncated(out interface{}) bool {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if hm, ok := m["has_more"].(bool); ok && hm {
|
||||
return true
|
||||
}
|
||||
ranges, _ := m["ranges"].([]interface{})
|
||||
if len(ranges) > 0 {
|
||||
if r0, ok := ranges[0].(map[string]interface{}); ok {
|
||||
if t, ok := r0["truncated"].(bool); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
|
||||
// or "" for an empty sheet.
|
||||
//
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1687,3 +1688,66 @@ func TestValidColumnType_AcceptsEmpty(t *testing.T) {
|
||||
t.Error(`validColumnType("float") = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_CharBudgetSpansTheWholeWorkbook pins that --max-chars bounds the
|
||||
// WHOLE multi-sheet read, not each sheet independently.
|
||||
//
|
||||
// The cap is a memory guard on a non-streaming path, so letting every sheet
|
||||
// spend it in full would let a 30-sheet workbook pull 30x what the caller
|
||||
// allowed — quietly, since each individual request looks compliant. The clamp
|
||||
// that prevents it (charBudget in readSheetAsSpec) was unpinned: removing it
|
||||
// passed the entire suite, because the outer loop's exhaustion check is a
|
||||
// separate mechanism and keeps working.
|
||||
//
|
||||
// Asserted on the wire: sheet 2's request must ask for less than sheet 1's.
|
||||
func TestTableGet_CharBudgetSpansTheWholeWorkbook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const budget = 40000
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[`+
|
||||
`{"sheet_id":"sh1","sheet_name":"S1","row_count":50,"column_count":3,"index":0},`+
|
||||
`{"sheet_id":"sh2","sheet_name":"S2","row_count":50,"column_count":3,"index":1}`+
|
||||
`]}`)
|
||||
|
||||
// One reusable stub answers both the current-region probes and the cell
|
||||
// reads; every captured body is inspected below.
|
||||
payload := `{"current_region":"A1:B2","ranges":[{"cells":[` +
|
||||
`[{"value":"col1"},{"value":"col2"}],` +
|
||||
`[{"value":"a"},{"value":"b"}]` +
|
||||
`]}]}`
|
||||
reads := toolOutputStub(testToken, "read", payload)
|
||||
reads.Reusable = true
|
||||
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL, "--max-chars", strconv.Itoa(budget)}, structure, reads)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
|
||||
var caps []int
|
||||
for _, body := range reads.CapturedBodies {
|
||||
var wire struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Input string `json:"input"`
|
||||
}
|
||||
if json.Unmarshal(body, &wire) != nil || wire.ToolName != "get_cell_ranges" {
|
||||
continue
|
||||
}
|
||||
var input struct {
|
||||
MaxChars int `json:"max_chars"`
|
||||
}
|
||||
if json.Unmarshal([]byte(wire.Input), &input) != nil || input.MaxChars == 0 {
|
||||
continue
|
||||
}
|
||||
caps = append(caps, input.MaxChars)
|
||||
}
|
||||
if len(caps) < 2 {
|
||||
t.Fatalf("want a cell read per sheet, captured caps = %v", caps)
|
||||
}
|
||||
if caps[0] > budget {
|
||||
t.Errorf("first sheet asked for max_chars=%d, over the %d budget", caps[0], budget)
|
||||
}
|
||||
if caps[1] >= caps[0] {
|
||||
t.Errorf("second sheet asked for max_chars=%d, not reduced by what the first consumed (%d) — the budget is per-workbook, not per-sheet", caps[1], caps[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/drive"
|
||||
@@ -405,7 +407,11 @@ var SheetCopy = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+sheet-copy"),
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
|
||||
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
|
||||
},
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -914,6 +920,18 @@ type workbookCreateStylePayload struct {
|
||||
RowSizes []workbookCreateResizeOp
|
||||
ColSizes []workbookCreateResizeOp
|
||||
CellMerges []workbookCreateMergeOp
|
||||
Freeze *workbookCreateFreezeOp
|
||||
}
|
||||
|
||||
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
|
||||
// Zero means "that axis ends up UNFROZEN", not "leave it alone": freeze is
|
||||
// full-state replacement server-side (see workbookCreateVisualOpInput's freeze
|
||||
// branch), so a declarative spec that omits an axis is stating it should not be
|
||||
// frozen. parseWorkbookCreateFreezeOp rejects an all-zero op, so at least one
|
||||
// axis is always positive here.
|
||||
type workbookCreateFreezeOp struct {
|
||||
Rows int
|
||||
Cols int
|
||||
}
|
||||
|
||||
type workbookCreateCellStyleOp struct {
|
||||
@@ -965,7 +983,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
|
||||
if len(items) != 1 {
|
||||
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
|
||||
}
|
||||
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||||
@@ -988,21 +1010,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
|
||||
}
|
||||
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
|
||||
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
|
||||
var probs []error
|
||||
for i, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
|
||||
continue
|
||||
}
|
||||
if name != payload.Sheets[i].Name {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
|
||||
continue
|
||||
}
|
||||
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
out.ByIndex[i] = style
|
||||
out.ByName[name] = style
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1030,182 +1059,468 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
|
||||
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
|
||||
// are validated even after one fails, and every issue is returned in the
|
||||
// slice: eval traces show agents fixing --styles errors one round trip per
|
||||
// error (border side, then row_sizes.type, then size…) because only the
|
||||
// first was ever reported.
|
||||
// workbookCreateStyleItemKeys is the full top-level vocabulary of one
|
||||
// --styles item, shared by the three carriers (+workbook-create /
|
||||
// +table-put / +styles-put).
|
||||
var workbookCreateStyleItemKeys = []string{"name", "cell_styles", "row_sizes", "col_sizes", "cell_merges", "freeze"}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
|
||||
payload := &workbookCreateStylePayload{}
|
||||
var err error
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var probs []error
|
||||
// Reject unknown top-level keys first: a typo like "freezee" would
|
||||
// otherwise be silently dropped while the rest of the item applies.
|
||||
var unknown []string
|
||||
for k := range item {
|
||||
known := false
|
||||
for _, lk := range workbookCreateStyleItemKeys {
|
||||
if k == lk {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
for _, k := range unknown {
|
||||
msg := fmt.Sprintf("%s has unknown key %q", path, k)
|
||||
if match := suggest.Closest(strings.ToLower(k), workbookCreateStyleItemKeys, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
probs = append(probs, common.ValidationErrorf("%s", msg))
|
||||
}
|
||||
// Normalize "Sheet!" range prefixes before the section parsers see them:
|
||||
// the target sheet is named by the item (or, on +workbook-create --values,
|
||||
// by the single sheet being created), so a prefix is at best redundant and
|
||||
// at worst a silent retarget. Stripping is unconditional — an item without
|
||||
// a name (the --values path, where name is optional) must not be left with
|
||||
// prefixed ranges the section parsers then reject as malformed. Only the
|
||||
// "names a DIFFERENT sheet" report needs a name to compare against, so it
|
||||
// is skipped when there is none.
|
||||
name, _ := item["name"].(string)
|
||||
probs = append(probs, normalizeStyleItemRangePrefixes(item, path, strings.TrimSpace(name))...)
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
var errsHere []error
|
||||
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["row_sizes"]; ok {
|
||||
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["col_sizes"]; ok {
|
||||
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["cell_merges"]; ok {
|
||||
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
var errsHere []error
|
||||
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["freeze"]; ok {
|
||||
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
} else {
|
||||
payload.Freeze = freeze
|
||||
}
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
|
||||
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
|
||||
if len(probs) > 0 {
|
||||
return nil, probs
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
|
||||
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
|
||||
// styleItemRangeSections are the --styles item sections whose entries carry an
|
||||
// A1 range that may be written with a redundant "Sheet!" prefix.
|
||||
var styleItemRangeSections = []string{"cell_styles", "row_sizes", "col_sizes", "cell_merges"}
|
||||
|
||||
// normalizeStyleItemRangePrefixes strips an optional "Sheet!" prefix from every
|
||||
// range in one --styles item, in place, and reports the ones naming a sheet
|
||||
// other than the item's own.
|
||||
//
|
||||
// Stripping has to happen before the section parsers run: parseWorkbookCreateResizeOp
|
||||
// feeds the range straight to parseA1Range, so row_sizes like "Sheet1!2:3" fail
|
||||
// as malformed even though the intent is unambiguous — the target sheet is
|
||||
// already carried by the item name and by each expanded sub-op's sheet selector.
|
||||
// A prefix naming a DIFFERENT sheet is an error rather than a strip, because
|
||||
// stripping alone would silently retarget the operation onto the item's sheet
|
||||
// (name "Summary" + range "Detail!A1:D1" applying to Summary). It is stripped
|
||||
// anyway so the section parser reports the entry's own issues instead of piling
|
||||
// a redundant syntax error on top of the mismatch.
|
||||
//
|
||||
// name is "" on +workbook-create --values, whose single styles item needs no
|
||||
// name (the workbook has exactly one sheet, still unnamed at spec time). There
|
||||
// is then no sheet to disagree with, so ranges are stripped without the
|
||||
// mismatch report — stripping still has to happen, or the section parsers see
|
||||
// a prefixed range and reject it as malformed.
|
||||
func normalizeStyleItemRangePrefixes(item map[string]interface{}, path, name string) []error {
|
||||
var probs []error
|
||||
rewrite := func(section, rangeStr string) (string, bool) {
|
||||
idx := strings.Index(rangeStr, "!")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
prefix := strings.Trim(strings.TrimSpace(rangeStr[:idx]), "'")
|
||||
if name != "" && prefix != name {
|
||||
probs = append(probs, common.ValidationErrorf(
|
||||
"%s.%s range %q names sheet %q but the item targets %q — drop the prefix, or move the entry into the item for %q",
|
||||
path, section, rangeStr, prefix, name, prefix))
|
||||
}
|
||||
return strings.TrimSpace(rangeStr[idx+1:]), true
|
||||
}
|
||||
for _, key := range styleItemRangeSections {
|
||||
arr, ok := item[key].([]interface{})
|
||||
if !ok {
|
||||
continue // a wrong-shaped section is the section parser's to report.
|
||||
}
|
||||
for i, elem := range arr {
|
||||
section := fmt.Sprintf("%s[%d]", key, i)
|
||||
switch v := elem.(type) {
|
||||
case map[string]interface{}:
|
||||
rangeStr, ok := v["range"].(string)
|
||||
if !ok {
|
||||
continue // non-string/missing range: the section parser reports it.
|
||||
}
|
||||
if stripped, changed := rewrite(section, rangeStr); changed {
|
||||
v["range"] = stripped
|
||||
}
|
||||
case string:
|
||||
// cell_merges also accepts a bare range string.
|
||||
if key != "cell_merges" {
|
||||
continue
|
||||
}
|
||||
if stripped, changed := rewrite(section, v); changed {
|
||||
arr[i] = stripped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. At least
|
||||
// one dimension must be positive — an all-zero freeze is a no-op the caller
|
||||
// almost certainly didn't mean.
|
||||
func parseWorkbookCreateFreezeOp(raw interface{}, path string) (*workbookCreateFreezeOp, error) {
|
||||
obj, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
|
||||
}
|
||||
// "cols" and "columns" are aliases for the same field, so accepting both in
|
||||
// one object would make the result depend on Go's randomized map iteration
|
||||
// order — the same payload could freeze 1 column on one run and 2 on the
|
||||
// next. Reject the conflict instead of silently picking a winner.
|
||||
if _, hasCols := obj["cols"]; hasCols {
|
||||
if _, hasColumns := obj["columns"]; hasColumns {
|
||||
if !jsonEqual(obj["cols"], obj["columns"]) {
|
||||
return nil, common.ValidationErrorf("%s got conflicting values for \"cols\" and \"columns\" (aliases of the same field) — keep one", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := &workbookCreateFreezeOp{}
|
||||
// Iterate deterministically so error reporting is stable across runs too.
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v := obj[k]
|
||||
n, isNum := v.(float64)
|
||||
if !isNum || n != float64(int(n)) || n < 0 {
|
||||
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
|
||||
}
|
||||
switch k {
|
||||
case "rows":
|
||||
out.Rows = int(n)
|
||||
case "cols", "columns":
|
||||
out.Cols = int(n)
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
|
||||
}
|
||||
}
|
||||
if out.Rows == 0 && out.Cols == 0 {
|
||||
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0)", path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinStyleValidationErrors folds the issues collected across one --styles
|
||||
// parse into a single typed error that lists them all, so the caller can fix
|
||||
// the whole payload in one retry instead of one error per round trip.
|
||||
func joinStyleValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
// Re-attribute to the outer flag even for a single issue: the inner
|
||||
// error is scoped to a nested path and carries no Param, so an agent
|
||||
// would have to parse prose to learn which flag to fix. Message text
|
||||
// is preserved; only the typed attribution is added — and the inner
|
||||
// hint rides along, since a lone issue has the outer Hint slot free.
|
||||
msg, hint := aggregatedIssueParts(probs[0])
|
||||
verr := sheetsValidationForFlag("styles", "%s", msg).WithCause(probs[0])
|
||||
if hint != "" {
|
||||
verr = verr.WithHint("%s", hint)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
msgs = append(msgs, aggregatedIssueText(e))
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return sheetsValidationForFlag("styles", "--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
|
||||
WithCause(probs[0])
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
|
||||
}
|
||||
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
|
||||
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
|
||||
}
|
||||
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
|
||||
}
|
||||
mergeType = strings.TrimSpace(v)
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
|
||||
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
|
||||
// A bare range string means {range: s, merge_type: all} — the only
|
||||
// possible reading (07-20 eval hit).
|
||||
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
||||
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
|
||||
}
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
|
||||
}
|
||||
mergeType = normalizeMergeType(strings.TrimSpace(v))
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
|
||||
}
|
||||
|
||||
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
|
||||
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
|
||||
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
|
||||
// the caller's enum check to reject.
|
||||
func normalizeMergeType(v string) string {
|
||||
lower := strings.ToLower(v)
|
||||
lower = strings.TrimPrefix(lower, "merge_")
|
||||
switch lower {
|
||||
case "all", "rows", "columns":
|
||||
return lower
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
// resizeOpExample renders a complete valid op for the dimension, inlined on
|
||||
// every type/size error: eval traces show the field errors chaining (type
|
||||
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
|
||||
// trip, because no error ever showed a whole valid op at once.
|
||||
func resizeOpExample(dimension string) string {
|
||||
if dimension == "column" {
|
||||
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
|
||||
}
|
||||
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType != "" {
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
n, ok := util.ToFloat64(raw)
|
||||
if !ok || n <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
|
||||
}
|
||||
return ops, nil
|
||||
// size is the canonical dimension key (uniform across row_sizes and
|
||||
// col_sizes — the array name already carries the dimension). The Excel-
|
||||
// vocabulary alias (height on rows, width on columns) is accepted
|
||||
// silently; the WRONG dimension's word is a targeted error, never a
|
||||
// silent rewrite.
|
||||
alias, wrongDim := "height", "width"
|
||||
if dimension == "column" {
|
||||
alias, wrongDim = "width", "height"
|
||||
}
|
||||
if _, has := op[wrongDim]; has {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
|
||||
}
|
||||
sizeRaw, hasSize := op["size"]
|
||||
if aliasRaw, hasAlias := op[alias]; hasAlias {
|
||||
if hasSize {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
|
||||
}
|
||||
sizeRaw, hasSize = aliasRaw, true
|
||||
}
|
||||
size := 0
|
||||
if hasSize {
|
||||
n, ok := util.ToFloat64(sizeRaw)
|
||||
if !ok || n <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
// type is optional ceremony when a pixel size is given: {range, size}
|
||||
// means a pixel resize, exactly as --width/--height without --type does
|
||||
// on the flag path. Explicit standard/auto still needs type.
|
||||
if resizeType == "" {
|
||||
if size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
resizeType = "pixel"
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
|
||||
}
|
||||
|
||||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||||
@@ -1245,6 +1560,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := foldBorderFamilyAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeCellStyleAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1259,15 +1577,33 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
if err := validateWorkbookBorderStyles(m, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["border_styles"] = m
|
||||
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
|
||||
return nil, common.ValidationErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
|
||||
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
|
||||
default:
|
||||
if !workbookCreateCellStyleField(k) {
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
|
||||
// Universal rejection with the full field list: this is the
|
||||
// mechanism that absorbs the infinite tail of spelling
|
||||
// permutations at a fixed one-retry cost — silent aliases are
|
||||
// reserved for high-frequency words from real external
|
||||
// vocabularies (see the style_vocab.go contract). A curated
|
||||
// prescription wins over did-you-mean; without one, the
|
||||
// distance match must be a near-typo (≤2 edits) — a
|
||||
// concept-swap neighbor (font_bold → font_color, distance 3)
|
||||
// misleads worse than silence.
|
||||
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
|
||||
lower := strings.ToLower(k)
|
||||
if rx, ok := styleFieldPrescriptions[lower]; ok {
|
||||
msg += " — " + rx
|
||||
} else if match := suggest.Closest(lower, workbookCreateCellStyleFieldList, 1); len(match) > 0 && suggest.Levenshtein(lower, match[0]) <= 2 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
|
||||
return nil, common.ValidationErrorf("%s", msg)
|
||||
}
|
||||
cellStyle[k] = v
|
||||
}
|
||||
@@ -1278,6 +1614,19 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// workbookCreateCellStyleFieldList is what a caller may WRITE in a cell_styles
|
||||
// item, in display order for the unknown-field hint — the canonical scalar
|
||||
// vocabulary (workbookCreateCellStyleField) plus the two border carriers.
|
||||
// "border" is the documented four-sides shorthand rather than a field the
|
||||
// switch above ever sees: foldBorderFamilyAliases folds it into border_styles
|
||||
// first. It belongs in this list because the list answers "what may I write",
|
||||
// not "what survives normalization".
|
||||
var workbookCreateCellStyleFieldList = []string{
|
||||
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap", "border", "border_styles",
|
||||
}
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
@@ -1299,7 +1648,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
|
||||
switch side {
|
||||
case "top", "bottom", "left", "right":
|
||||
default:
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
|
||||
}
|
||||
spec, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -1482,7 +1831,7 @@ func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID,
|
||||
}
|
||||
wireBody, _ := buildToolBody(toolName, input)
|
||||
dry.POST(toolInvokePath(token, ToolKindWrite)).
|
||||
Desc(fmt.Sprintf("apply %s %s", op.Kind, op.Range)).
|
||||
Desc(fmt.Sprintf("apply %s", op.describe())).
|
||||
Body(wireBody)
|
||||
}
|
||||
}
|
||||
@@ -1502,11 +1851,11 @@ func applyWorkbookCreateVisualOps(ctx context.Context, runtime *common.RuntimeCo
|
||||
// failing op as a recovery hint when one isn't already set.
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Hint == "" {
|
||||
p.Hint = fmt.Sprintf("failed while applying %s on %s", op.Kind, op.Range)
|
||||
p.Hint = fmt.Sprintf("failed while applying %s", op.describe())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s %s failed", op.Kind, op.Range).WithCause(err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s failed", op.describe()).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1516,7 +1865,7 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
|
||||
for _, op := range styles.CellMerges {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
|
||||
}
|
||||
@@ -1526,6 +1875,9 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
for _, op := range styles.ColSizes {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||||
}
|
||||
if styles.Freeze != nil {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze", FreezeRows: styles.Freeze.Rows, FreezeCols: styles.Freeze.Cols})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -1535,9 +1887,30 @@ type workbookCreateStyleOp struct {
|
||||
MergeType string
|
||||
ResizeType string
|
||||
Size int
|
||||
FreezeRows int
|
||||
FreezeCols int
|
||||
}
|
||||
|
||||
// describe renders the op for dry-run text and failure hints. freeze carries
|
||||
// counts instead of a range, so "%s %s" of kind and range would trail a blank.
|
||||
func (op workbookCreateStyleOp) describe() string {
|
||||
if op.Kind != "freeze" {
|
||||
return op.Kind + " " + op.Range
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if op.FreezeRows > 0 {
|
||||
parts = append(parts, fmt.Sprintf("rows=%d", op.FreezeRows))
|
||||
}
|
||||
if op.FreezeCols > 0 {
|
||||
parts = append(parts, fmt.Sprintf("cols=%d", op.FreezeCols))
|
||||
}
|
||||
return "freeze " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCreateStyleOp) (map[string]interface{}, string) {
|
||||
// Every caller names the sheet through the selector, so a "Sheet!" prefix
|
||||
// left on the range would be a duplicate the backend range parser rejects.
|
||||
op.Range = stripSheetPrefix(op.Range)
|
||||
switch op.Kind {
|
||||
case "cell_merge":
|
||||
input := map[string]interface{}{
|
||||
@@ -1564,6 +1937,26 @@ func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCr
|
||||
input["resize_width"] = block
|
||||
}
|
||||
return input, "resize_range"
|
||||
case "freeze":
|
||||
// Both axes travel in ONE operation because the backend treats freeze as
|
||||
// full-state replacement, not a per-axis patch: verified 07-31 on a live
|
||||
// sheet — freezing 1 row then 2 columns in two calls ends at
|
||||
// frozen_row_count 0 / frozen_column_count 2, the second call having
|
||||
// silently dropped the first axis. One call carrying both lands 1/2.
|
||||
// By the same rule an omitted axis is unfrozen, which is what a
|
||||
// declarative --styles spec should mean.
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "freeze",
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op.FreezeRows > 0 {
|
||||
input["freeze_rows"] = op.FreezeRows
|
||||
}
|
||||
if op.FreezeCols > 0 {
|
||||
input["freeze_columns"] = op.FreezeCols
|
||||
}
|
||||
return input, "modify_sheet_structure"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
@@ -520,7 +520,8 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"styles item unknown key gets did-you-mean", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","freezee":{"rows":1}}]}`}, `unknown key "freezee" — did you mean "freeze"`},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
@@ -689,3 +690,143 @@ func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStyleItemRangePrefixNormalization pins the "Sheet!" prefix handling at the
|
||||
// shared item parser, so all three --styles carriers (+workbook-create,
|
||||
// +table-put, +styles-put) behave the same: a prefix naming the item's own
|
||||
// sheet is stripped (row_sizes would otherwise fail parseA1Range), one naming a
|
||||
// different sheet is reported instead of silently retargeting.
|
||||
func TestStyleItemRangePrefixNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("own-sheet prefix strips across every section", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Summary!A1:D1", "font_weight": "bold"}},
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "'Summary'!A2:B2"}, "Summary!C2:D2"},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Summary!2:3", "type": "pixel", "size": float64(32)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "'Summary'!A:C", "type": "pixel", "size": float64(120)}},
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
if len(probs) > 0 {
|
||||
t.Fatalf("a redundant own-sheet prefix must be accepted: %v", probs)
|
||||
}
|
||||
got := []string{
|
||||
payload.CellStyles[0].Range,
|
||||
payload.CellMerges[0].Range, payload.CellMerges[1].Range,
|
||||
payload.RowSizes[0].Range, payload.ColSizes[0].Range,
|
||||
}
|
||||
want := []string{"A1:D1", "A2:B2", "C2:D2", "2:3", "A:C"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ranges = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("foreign-sheet prefix reported alongside the item's other issues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Detail!A1:D1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "2:3", "type": "custom", "size": float64(32)}},
|
||||
}
|
||||
_, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
joined := make([]string, 0, len(probs))
|
||||
for _, p := range probs {
|
||||
joined = append(joined, p.Error())
|
||||
}
|
||||
all := strings.Join(joined, "\n")
|
||||
// Both must surface in one pass: stripping the mismatched prefix keeps the
|
||||
// section parser from burying the real issue under a syntax error.
|
||||
if !strings.Contains(all, `names sheet "Detail" but the item targets "Summary"`) {
|
||||
t.Fatalf("probs = %v, want the foreign-prefix issue", all)
|
||||
}
|
||||
if !strings.Contains(all, `row_sizes[0].type "custom" is invalid`) {
|
||||
t.Fatalf("probs = %v, want the type issue reported too", all)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unnamed item still gets its prefixes stripped", func(t *testing.T) {
|
||||
// +workbook-create --values' styles item needs no name (one sheet, not
|
||||
// yet named), but stripping must not be conditional on having one: the
|
||||
// section parsers feed ranges to parseA1Range, so a surviving prefix
|
||||
// turns an unambiguous spec into a malformed-range error. With no name
|
||||
// there is simply nothing to disagree with, so no mismatch is reported.
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Sheet1!A1:D1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Sheet1!1:1", "size": float64(30)}},
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
if len(probs) > 0 {
|
||||
t.Fatalf("unexpected probs: %v", probs)
|
||||
}
|
||||
if payload.CellStyles[0].Range != "A1:D1" {
|
||||
t.Fatalf("cell_styles range = %q, want the prefix stripped", payload.CellStyles[0].Range)
|
||||
}
|
||||
if payload.RowSizes[0].Range != "1:1" {
|
||||
t.Fatalf("row_sizes range = %q, want the prefix stripped", payload.RowSizes[0].Range)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all three carriers accept a prefixed row_sizes range", func(t *testing.T) {
|
||||
// The regression this guards: prefix stripping used to live only on the
|
||||
// named-item path, so +workbook-create --values (whose item carries no
|
||||
// name) still failed on "Sheet1!2:3" while +table-put / +styles-put
|
||||
// accepted it.
|
||||
t.Parallel()
|
||||
for _, name := range []string{"", "Sheet1"} {
|
||||
item := map[string]interface{}{
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Sheet1!2:3", "size": float64(30)}},
|
||||
}
|
||||
if name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
if len(probs) > 0 {
|
||||
t.Fatalf("name=%q: unexpected probs: %v", name, probs)
|
||||
}
|
||||
if payload.RowSizes[0].Range != "2:3" {
|
||||
t.Fatalf("name=%q: range = %q, want %q", name, payload.RowSizes[0].Range, "2:3")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWorkbookCreateVisualOpInput pins what the shared visual-op builder emits:
|
||||
// one combined freeze operation, and a range with no sheet prefix (the sheet
|
||||
// travels in the selector).
|
||||
func TestWorkbookCreateVisualOpInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("freeze rows and columns share one operation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops := workbookCreateVisualOps(&workbookCreateStylePayload{
|
||||
Freeze: &workbookCreateFreezeOp{Rows: 1, Cols: 2},
|
||||
})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("ops = %d, want 1 combined freeze (a second call resets the first axis — verified live 07-31)", len(ops))
|
||||
}
|
||||
input, toolName := workbookCreateVisualOpInput(testToken, "sheet-id", "", ops[0])
|
||||
if toolName != "modify_sheet_structure" {
|
||||
t.Fatalf("toolName = %q", toolName)
|
||||
}
|
||||
if input["freeze_rows"] != 1 || input["freeze_columns"] != 2 {
|
||||
t.Fatalf("input = %v, want both axes", input)
|
||||
}
|
||||
if got := ops[0].describe(); got != "freeze rows=1 cols=2" {
|
||||
t.Errorf("describe() = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet prefix is stripped off the range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input, toolName := workbookCreateVisualOpInput(testToken, "", "Summary",
|
||||
workbookCreateStyleOp{Kind: "cell_merge", Range: "Summary!A1:B2", MergeType: "all"})
|
||||
if toolName != "merge_cells" || input["range"] != "A1:B2" {
|
||||
t.Fatalf("input = %v (%s), want range A1:B2", input, toolName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -38,7 +39,11 @@ import (
|
||||
|
||||
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
|
||||
// (JSON), with an optional --copy-to-range to replicate the written block
|
||||
// across a larger area (formula refs auto-shift).
|
||||
// across a larger area (formula refs auto-shift). The plural form --writes
|
||||
// ([{sheet_name, range, cells}, …]) fans scattered regions — cross-sheet
|
||||
// allowed — into ONE atomic batch_update: eval traces show "fix all broken
|
||||
// formulas across ranges/sheets" as the dominant homogeneous scenario still
|
||||
// hand-assembled as +batch-update operations arrays.
|
||||
var CellsSet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set",
|
||||
@@ -48,9 +53,31 @@ var CellsSet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set"),
|
||||
Validate: validateViaInput(cellsSetInput),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
|
||||
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
|
||||
`Scattered regions (e.g. fixing formulas across ranges/sheets): --writes '[{"sheet_name":…,"range":…,"cells":[[…]]}, …]' — one batch request (fail-fast, no rollback), sheet selector inside each item.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("writes") {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cellsSetWritesOps(runtime, token)
|
||||
return err
|
||||
}
|
||||
return validateViaInput(cellsSetInput)(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
if runtime.Changed("writes") {
|
||||
ops, _ := cellsSetWritesOps(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
@@ -60,6 +87,21 @@ var CellsSet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("writes") {
|
||||
ops, err := cellsSetWritesOps(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,6 +119,120 @@ var CellsSet = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// cellsSetWritesOps parses --writes ([{sheet_name|sheet_id, range, cells}, …])
|
||||
// and expands it into set_cell_range operations for ONE atomic batch_update.
|
||||
// Single source of truth per item: the sheet selector LIVES IN THE ITEM (same
|
||||
// convention as +batch-update sub-ops and +styles-put items — no top-level
|
||||
// fallback, no precedence table to remember). Every item runs through the
|
||||
// exact standalone pipeline (key vocabulary, style acceptance layer, matrix
|
||||
// precheck, schema validation) via a per-item flag view, and item errors are
|
||||
// aggregated so one retry fixes them all.
|
||||
func cellsSetWritesOps(runtime *common.RuntimeContext, token string) ([]interface{}, error) {
|
||||
for _, conflicting := range []string{"range", "cells", "copy-to-range"} {
|
||||
if runtime.Changed(conflicting) {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes and --%s are mutually exclusive: single region → --range + --cells; multiple regions → --writes alone", conflicting)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("sheet-name")) != "" || strings.TrimSpace(runtime.Str("sheet-id")) != "" {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes does not accept a top-level sheet selector — put sheet_name (or sheet_id) inside each writes item, same as +batch-update sub-ops")
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "writes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes must be a non-empty JSON array of {sheet_name, range, cells} items")
|
||||
}
|
||||
if len(raw) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes accepts at most %d items; got %d — merge adjacent regions or split into several calls", maxBatchOperations, len(raw))
|
||||
}
|
||||
topLevelOverwrite := runtime.Bool("allow-overwrite")
|
||||
ops := make([]interface{}, 0, len(raw))
|
||||
var probs []error
|
||||
var totalCells int64
|
||||
for i, v := range raw {
|
||||
item, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d] must be an object like {\"sheet_name\":…,\"range\":…,\"cells\":[[…]]}", i))
|
||||
continue
|
||||
}
|
||||
if err := normalizeSubOpInputKeys("+cells-set", item); err != nil {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
|
||||
continue
|
||||
}
|
||||
if topLevelOverwrite {
|
||||
if _, has := item["allow_overwrite"]; !has {
|
||||
item["allow_overwrite"] = true
|
||||
}
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+cells-set", item)
|
||||
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
|
||||
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
|
||||
input, err := cellsSetInput(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
// Prefix with the item index WITHOUT flattening: cellsSetInput's
|
||||
// errors carry the domain's prescriptions in Hint (requireSheetSelector's
|
||||
// "+workbook-info" pointer, for one) and "%v" would render only the
|
||||
// message, silently costing exactly the guidance this path exists to
|
||||
// deliver. joinWritesValidationErrors re-reads both fields.
|
||||
probs = append(probs, prefixValidationIssue(fmt.Sprintf("--writes[%d]", i), err))
|
||||
continue
|
||||
}
|
||||
if cells, ok := input["cells"].([]interface{}); ok {
|
||||
for _, row := range cells {
|
||||
if r, ok := row.([]interface{}); ok {
|
||||
totalCells += int64(len(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := checkBatchStampBudget("writes", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
if err := joinWritesValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// joinWritesValidationErrors mirrors joinStyleValidationErrors for --writes:
|
||||
// every item's first error in one message, so the whole payload is fixed in
|
||||
// a single retry.
|
||||
func joinWritesValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
// Re-attribute to the outer flag even for a single issue: the inner
|
||||
// error is scoped to a nested path and carries no Param, so an agent
|
||||
// would have to parse prose to learn which flag to fix. Message text
|
||||
// is preserved; only the typed attribution is added — and the inner
|
||||
// hint rides along, since a lone issue has the outer Hint slot free.
|
||||
msg, hint := aggregatedIssueParts(probs[0])
|
||||
verr := sheetsValidationForFlag("writes", "%s", msg).WithCause(probs[0])
|
||||
if hint != "" {
|
||||
verr = verr.WithHint("%s", hint)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
msgs = append(msgs, aggregatedIssueText(e))
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return sheetsValidationForFlag("writes", "--writes has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
|
||||
WithCause(probs[0])
|
||||
}
|
||||
|
||||
func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -91,9 +247,13 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if err := checkCellsMatchRange(cells, rangeStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"range": rangeStr,
|
||||
"cells": cells,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
@@ -124,7 +284,11 @@ var CellsSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set-style"),
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
|
||||
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
|
||||
},
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -310,33 +474,92 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
|
||||
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
|
||||
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
|
||||
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
|
||||
// would otherwise be written silently as the literal text "data.csv". It runs
|
||||
// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is
|
||||
// already its contents (a real CSV blob, never a path) and only a bare value
|
||||
// reaches here unchanged. It flags the value only when it actually names an
|
||||
// existing file in the cwd subtree; checking real existence (not name shape)
|
||||
// means inline content that merely ends in a filename ("see config.json") is
|
||||
// never misjudged. Fails open: any Stat error or a directory leaves the value
|
||||
// untouched. Scoped to --csv only — no other flag is affected.
|
||||
// would otherwise be written silently as the literal text "data.csv" — a wrong
|
||||
// value in the sheet plus a success exit code, which costs more than a
|
||||
// rejection because nothing surfaces it. It runs in +csv-put's Validate, after
|
||||
// resolveInputFlags — so an @file / stdin value is already its contents (a real
|
||||
// CSV blob, never a path) and only a bare value reaches here unchanged.
|
||||
//
|
||||
// Two tiers, because the fix differs:
|
||||
//
|
||||
// - the value names an existing file in the cwd subtree → a forgotten "@";
|
||||
// - the file does not exist but the value is unmistakably path-shaped →
|
||||
// usually an absolute path (which "@" rejects) that the caller retried
|
||||
// without the "@", or a stale relative path from another working
|
||||
// directory. Same silent-write outcome, different prescription: stdin.
|
||||
//
|
||||
// Everything else passes through. Existence alone can't carry tier two, so
|
||||
// shape does — but only the narrow shape defined by csvValueLooksLikePath,
|
||||
// which is what keeps prose that merely mentions a filename out of it.
|
||||
// Fails open: any Stat error or a directory falls through to the shape check.
|
||||
// Scoped to --csv only — no other flag is affected.
|
||||
//
|
||||
// A value that arrived via @file / stdin is skipped entirely
|
||||
// (InputResolvedFromSource): its content was already read from the right
|
||||
// place and may legitimately look like anything, including a path. That
|
||||
// also makes stdin the guard-proof way to write such text verbatim.
|
||||
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
|
||||
if runtime.InputResolvedFromSource("csv") {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(runtime.Str("csv"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
// Hints below use <path> placeholders instead of echoing the raw value
|
||||
// into command-shaped text: the value is untrusted, and a hint like
|
||||
// "--csv - < $(id).csv" hands an agent a copy-pasteable command that a
|
||||
// POSIX shell would expand.
|
||||
if fio := runtime.FileIO(); fio != nil {
|
||||
info, err := fio.Stat(raw)
|
||||
if err == nil && info != nil && !info.IsDir() {
|
||||
return sheetsValidationForFlag("csv",
|
||||
"--csv value %q is an existing file, not inline CSV; to read it, pass the same path with an @ prefix (--csv @<path>), or pipe the literal text via stdin (--csv -)",
|
||||
raw,
|
||||
)
|
||||
}
|
||||
}
|
||||
if !csvValueLooksLikePath(raw) {
|
||||
return nil
|
||||
}
|
||||
info, err := fio.Stat(raw)
|
||||
if err != nil || info == nil || info.IsDir() {
|
||||
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
|
||||
}
|
||||
return sheetsValidationForFlag("csv",
|
||||
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
|
||||
raw, raw,
|
||||
"--csv value %q looks like a file path, not inline CSV, and no such file exists under the current directory",
|
||||
raw,
|
||||
).WithHint(
|
||||
"to read a file: --csv @<path> (relative to the current directory; @ rejects absolute paths — pipe such a file in via stdin instead: --csv - < <path>). To write this text into the cell verbatim, pass it on stdin the same way (--csv -); values arriving via stdin or @file skip this check",
|
||||
)
|
||||
}
|
||||
|
||||
// csvValueLooksLikePath reports whether a --csv value is unmistakably a path
|
||||
// rather than CSV content. Deliberately narrow: the guard rejects on it, so a
|
||||
// false positive blocks a legitimate write, and an earlier name-shape
|
||||
// heuristic was replaced by an existence check precisely because it misjudged
|
||||
// prose ("改完记得更新config.json"). Three conditions, all required:
|
||||
//
|
||||
// no comma / newline / whitespace — real CSV has separators, prose has spaces
|
||||
// pure ASCII — CJK text is content, never a path here
|
||||
// a .csv/.tsv extension, or an explicit ./ ../ / ~/ prefix
|
||||
//
|
||||
// The extension-or-prefix rule is what keeps ordinary single-cell values safe:
|
||||
// "N/A" contains a slash but neither, and "README.md" is a filename but not a
|
||||
// CSV one. A caller who genuinely means such a literal still has stdin.
|
||||
func csvValueLooksLikePath(s string) bool {
|
||||
if strings.ContainsAny(s, ", \t\r\n\"") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
if strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".tsv") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../") ||
|
||||
strings.HasPrefix(s, "/") || strings.HasPrefix(s, "~/")
|
||||
}
|
||||
|
||||
func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -625,6 +848,43 @@ func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
|
||||
// and returns its row / column counts. Errors on non-rectangular forms like
|
||||
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
|
||||
// from get_sheet_structure, outside the scope of pure local parsing.
|
||||
// checkCellsMatchRange rejects, before any network call, the cells-vs-range
|
||||
// mismatches the server would otherwise fail mid-batch ("cells row count (N)
|
||||
// does not match range row count (M)" — a recurring server-side error cluster
|
||||
// in eval traces, and the failure leaves earlier batch sub-ops applied).
|
||||
// Single-cell ranges are checked too: the server enforces the same strict
|
||||
// match on a bare "A1" (07-21 rerun, 12 rows against range row count 1) —
|
||||
// there is no anchor semantics on +cells-set. An unparsable range is the
|
||||
// range validator's job, not ours.
|
||||
func checkCellsMatchRange(cells []interface{}, rangeStr string) error {
|
||||
if len(cells) == 0 {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells is empty; to clear values use +cells-clear --scope content (needs --yes), or pass a non-empty 2D array")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // an unparsable range is reported by the range validation path with proper context
|
||||
}
|
||||
if len(cells) != rows {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells has %d rows but --range %q spans %d rows; make them equal (e.g. write N rows to an N-row range)",
|
||||
len(cells), rangeStr, rows)
|
||||
}
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells[%d] must be an array (one row of cells) — --cells is always a 2D array, a single cell is [[{…}]]", r)
|
||||
}
|
||||
if len(row) != cols {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells[%d] has %d columns but --range %q spans %d columns; every row must match the range width",
|
||||
r, len(row), rangeStr, cols)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
rangeStr = rangeStr[idx+1:]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user