Compare commits

..

1 Commits

Author SHA1 Message Date
niezhiwei
7f9c6d9bfb fix(slides): reindent xml-get output with a stdlib-only formatter
Reland of #1987 (reverted in #2013 over the BSD attribution gap of
github.com/beevik/etree) with the formatter rebuilt on the Go standard
library only — no third-party dependency.

encoding/xml serves purely as a tokenizer; the output is assembled
exclusively from verbatim byte slices of the server content plus
indentation inserted between structural elements. Nothing is
parsed-and-reserialized, so CDATA sections, whitespace character
references in any spelling, entity lexical forms, attribute quoting,
and in-tag whitespace survive byte-for-byte — the character-reference
masking machinery of the etree implementation is no longer needed.

Behavior is unchanged from the reverted PR: --raw stdout and --output
files are reindented (never inside the schema's mixed-content
text-bearing elements), the default JSON envelope carries the server's
XML verbatim without parsing, and formatting failures fall back to the
original content with a stderr warning and pretty_printed: false in
--output file metadata. All contract tests carry over unweakened; a
differential probe against the etree implementation over 53 inputs was
byte-identical except six cases where the new formatter preserves the
original bytes more faithfully (each pinned in tests).
2026-07-23 02:14:06 +08:00
89 changed files with 1366 additions and 5516 deletions

View File

@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
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

View File

@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -31,7 +31,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -1,130 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -22,7 +22,6 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -34,7 +33,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -55,10 +54,9 @@ 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)
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -69,7 +67,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Runtime config contains resolved account data only.
// Phase 3: Config derived from Credential via an explicit conversion boundary.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -80,9 +78,8 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
return f
}
@@ -111,16 +108,13 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
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
@@ -134,7 +128,7 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -148,15 +142,8 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
if f.IOStreams.StderrIsTerminal {
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: sdkTransport,
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -165,8 +152,9 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,15 +6,10 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
@@ -34,10 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -45,10 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,7 +8,6 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -37,15 +36,13 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -76,7 +73,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
if _, err := cachedLarkClientFunc(f)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -15,7 +15,6 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -92,13 +91,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// wrapSDKTransport chain composition
// buildSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -111,23 +110,18 @@ func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !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()
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(previous) })
t.Cleanup(func() { exttransport.Register(nil) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -144,23 +138,17 @@ func TestWrapSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !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()
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -173,13 +161,9 @@ func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !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)
}
}
// ---------------------------------------------------------------------------
@@ -277,40 +261,6 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
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.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// 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
@@ -327,7 +277,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by wrapSDKTransport.
// Replicate the SDK chain layering used by buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,18 +60,11 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,9 +60,7 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -86,9 +84,6 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -1,27 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -1,48 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -1,17 +0,0 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -1,20 +0,0 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -1,11 +0,0 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -1,143 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -1,44 +0,0 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -1,78 +0,0 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -17,13 +17,6 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,18 +211,6 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -23,32 +22,6 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -56,7 +29,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %w", flagName, err)
return "", fmt.Errorf("%s: %v", flagName, err)
}
return value, nil
}

View File

@@ -1,8 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,7 +4,6 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -72,72 +71,6 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

View File

@@ -12,23 +12,10 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -54,17 +41,13 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,34 +82,6 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,6 +14,7 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -46,7 +47,21 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -61,9 +76,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
if err != nil {
return err
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,7 +12,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -59,17 +58,22 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -83,18 +87,6 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -157,142 +149,6 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "high-risk-write",
Risk: "write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,7 +39,6 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -2056,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2357,7 +2357,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2426,7 +2425,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2475,7 +2473,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2487,33 +2484,6 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2550,7 +2520,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2585,7 +2554,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"io"
"io/fs"
"math"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
)
// ValidateLocalFileFlag validates that a local input path exists, is a regular
// file, and does not exceed maxBytes. Absolute and relative paths use
// the process filesystem namespace.
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return err
}
info, err := cmdutil.StatLocalFile(path)
if err != nil {
return localFileReadError(param, path, "inspect", err)
}
if err := localFileRegularError(param, path, info.Mode()); err != nil {
return err
}
if info.Size() > maxBytes {
return localFileSizeError(param, path, info.Size(), maxBytes)
}
return nil
}
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
// shortcuts. It accepts absolute and relative paths, enforces a hard size
// limit, and returns command-facing typed errors.
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return nil, err
}
f, err := cmdutil.OpenLocalFile(path)
if err != nil {
return nil, localFileReadError(param, path, "open", err)
}
defer func() {
if err := f.Close(); err != nil && retErr == nil {
data = nil
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
}
}()
openedInfo, err := f.Stat()
if err != nil {
return nil, localFileReadError(param, path, "inspect opened", err)
}
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
return nil, err
}
if openedInfo.Size() > maxBytes {
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
}
readLimit := maxBytes + 1
if maxBytes == math.MaxInt64 {
readLimit = maxBytes
}
data, err = io.ReadAll(io.LimitReader(f, readLimit))
if err != nil {
return nil, localFileReadError(param, path, "read", err)
}
if int64(len(data)) > maxBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
WithParam(param)
}
return data, nil
}
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
name, param, err := localFileFlagNames(flagName)
if err != nil {
return "", "", err
}
if ctx == nil || ctx.Cmd == nil {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
}
path = strings.TrimSpace(ctx.Str(name))
if path == "" {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
}
if _, err := validate.LocalInputPath(path); err != nil {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
WithParam(param).
WithCause(err)
}
if maxBytes < 0 {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
}
return path, param, nil
}
func localFileRegularError(param, path string, mode fs.FileMode) error {
if mode.IsRegular() {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is not a regular file", param, path).
WithParam(param)
}
func localFileReadError(param, path, op string, cause error) error {
if errors.Is(cause, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
WithParam(param).
WithCause(cause)
}
if errors.Is(cause, fs.ErrNotExist) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
WithParam(param).
WithCause(cause)
}
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
}
func localFileSizeError(param, path string, size, limit int64) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
WithParam(param)
}
func localFileFlagNames(flagName string) (name, param string, err error) {
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
if name == "" {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
}
return name, "--" + name, nil
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/spf13/cobra"
)
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
rctx := localFileTestRuntime(t, path)
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
}
got, err := rctx.ReadLocalFileFlag("file", 7)
if err != nil || string(got) != "content" {
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
}
}
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("file", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
}

View File

@@ -3,24 +3,11 @@
package slides
import (
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var presentationFlagAliases = []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
all := []common.Shortcut{
return []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
@@ -31,39 +18,4 @@ func Shortcuts() []common.Shortcut {
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
for i := range all {
if hasPresentationFlag(all[i].Flags) {
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
}
}
return all
}
func hasPresentationFlag(flags []common.Flag) bool {
for _, flag := range flags {
if flag.Name == "presentation" {
return true
}
}
return false
}
// withPresentationFlagAliases accepts common agent-generated spellings for
// --presentation without registering extra flags. The aliases therefore stay
// out of help and completion while resolving to the canonical flag at parse
// time, matching the zero-round-trip compatibility used by Sheets.
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
for _, alias := range presentationFlagAliases {
if name == alias {
return pflag.NormalizedName("presentation")
}
}
return pflag.NormalizedName(name)
})
}
}

View File

@@ -1,68 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWithPresentationFlagAliases(t *testing.T) {
for _, alias := range presentationFlagAliases {
t.Run(alias, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("presentation", "", "presentation reference")
withPresentationFlagAliases(nil)(cmd)
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Fatalf("read --presentation: %v", err)
}
if got != "presABC" {
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
}
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
}
})
}
}
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
count := 0
for _, shortcut := range Shortcuts() {
if !hasPresentationFlag(shortcut.Flags) {
continue
}
count++
if shortcut.PostMount == nil {
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
continue
}
cmd := &cobra.Command{Use: shortcut.Command}
cmd.Flags().String("presentation", "", "presentation reference")
shortcut.PostMount(cmd)
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
continue
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
continue
}
if got != "presABC" {
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
}
}
if count == 0 {
t.Fatal("expected at least one slides shortcut with --presentation")
}
}

View File

@@ -37,7 +37,9 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -18,19 +17,23 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
base := []string{"slides:presentation:screenshot"}
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {

View File

@@ -16,9 +16,10 @@ import (
)
// SlidesXMLGet fetches the full XML presentation content. When --output is
// provided it writes to a local file; otherwise it returns the XML in the
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
// and use --raw for direct XML stdout.
// provided it writes reindented XML to a local file, and --raw prints
// reindented XML to stdout; otherwise it returns the server's original
// content unmodified in the standard JSON envelope. Use --slide-id or
// --slide-number to fetch one page.
var SlidesXMLGet = common.Shortcut{
Service: "slides",
Command: "+xml-get",
@@ -30,8 +31,8 @@ var SlidesXMLGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
{Name: "output", Desc: "local XML output path; the saved file is formatted for readability; must be a relative path within the current directory; existing file is overwritten; omit to return the server's original XML in the JSON envelope"},
{Name: "raw", Type: "bool", Desc: "print formatted XML to stdout without the JSON envelope; incompatible with --output and --jq"},
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
@@ -108,10 +109,10 @@ var SlidesXMLGet = common.Shortcut{
}
dry.GET(path).Params(params)
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; formatted XML content is saved to --output during execution")
}
if runtime.Bool("raw") {
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
return dry.Set("output", "<stdout>").Set("stdout_content", "formatted XML content is printed to stdout during execution")
}
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
},
@@ -250,22 +251,31 @@ func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID str
return content, out, nil
}
// outputSlidesXMLGetContent routes the fetched XML to its output surface.
// Only the text surfaces are reindented: --raw stdout and --output files are
// read directly by humans and line tools. The JSON envelope carries the
// server content verbatim instead -- inside a JSON string every newline is
// escaped to \n, so formatting there buys no readability and only inflates
// the payload, while passthrough keeps that read path byte-exact without
// even parsing the content.
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
if outputPath == "" {
if !runtime.Bool("raw") {
runtime.OutFormatRaw(out, nil, nil)
return nil
}
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
formatted, _ := prettyPrintXMLOrOriginal(runtime, content)
if _, err := fmt.Fprint(runtime.IO().Out, formatted); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
}
return nil
}
formatted, prettyPrinted := prettyPrintXMLOrOriginal(runtime, content)
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),
}, bytes.NewReader([]byte(content)))
ContentLength: int64(len(formatted)),
}, bytes.NewReader([]byte(formatted)))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
@@ -280,6 +290,7 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
"path": resolvedPath,
"size": result.Size(),
"content_saved": true,
"pretty_printed": prettyPrinted,
}
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
if value, ok := out[key]; ok {
@@ -289,3 +300,17 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
runtime.Out(fileOut, nil)
return nil
}
// prettyPrintXMLOrOriginal keeps xml-get best-effort: if the server returns
// content that is not strictly valid XML, callers still receive the original
// content and a warning on stderr instead of losing the read path. The bool
// reports whether pretty-printing succeeded, surfaced as pretty_printed in
// --output file metadata.
func prettyPrintXMLOrOriginal(runtime *common.RuntimeContext, xmlContent string) (string, bool) {
out, err := prettyPrintXML(xmlContent)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: XML pretty-print skipped; returning original server content: %v\n", err)
return xmlContent, false
}
return out, true
}

View File

@@ -23,6 +23,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
// Golden value computed independently of prettyPrintXML (not derived by
// calling it): a bug in prettyPrintXML itself must not be able to make
// this assertion pass by construction.
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
@@ -60,10 +64,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
if err != nil {
t.Fatalf("read saved XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
if string(got) != wantXML {
t.Fatalf("saved XML = %q, want %q", got, wantXML)
}
if strings.Contains(stdout.String(), xml) {
if strings.Contains(stdout.String(), wantXML) {
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
}
if got := capturedQuery.Get("revision_id"); got != "7" {
@@ -80,8 +84,11 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
if data["revision_id"] != float64(7) {
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
}
if data["size"] != float64(len(xml)) {
t.Fatalf("size = %v, want %d", data["size"], len(xml))
if data["pretty_printed"] != true {
t.Fatalf("pretty_printed = %v, want true", data["pretty_printed"])
}
if data["size"] != float64(len(wantXML)) {
t.Fatalf("size = %v, want %d", data["size"], len(wantXML))
}
gotPath, _ := data["path"].(string)
if !filepath.IsAbs(gotPath) {
@@ -96,7 +103,12 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
// The JSON envelope carries the server content verbatim: no reindentation
// and no parse/reserialize cycle. Reintroducing the in-repo formatter
// would fail this by inserting indentation; the &#32; reference
// additionally guards against a naive parse-and-reserialize round trip,
// which would decode it to a literal space.
xml := `<presentation><slide id="s1"><shape id="a"><content><p><span>Hello</span>&#32;<strong>World</strong></p></content></shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -122,11 +134,14 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
data := decodeShortcutData(t, stdout)
presentation := data["xml_presentation"].(map[string]interface{})
if got := presentation["content"]; got != xml {
t.Fatalf("content = %q, want %q", got, xml)
t.Fatalf("content = %q, want the server content verbatim %q", got, xml)
}
if got := data["xml_presentation_id"]; got != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
if strings.Contains(stdout.String(), "content_saved") {
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
}
@@ -136,6 +151,8 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
// --jq extracts fields from the envelope, and the envelope carries the
// server content verbatim, so the filter yields the single-line original.
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
@@ -161,15 +178,18 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != xml {
t.Fatalf("stdout = %q, want XML content %q", got, xml)
t.Fatalf("stdout = %q, want the server content verbatim %q", got, xml)
}
}
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
// Golden value computed independently of prettyPrintXML; see the comment
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
@@ -193,16 +213,32 @@ func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != xml {
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
if got := stdout.String(); got != wantXML {
t.Fatalf("stdout = %q, want formatted XML %q", got, wantXML)
}
}
func TestSlidesXMLGetRawFlagDocumentsFormattedOutput(t *testing.T) {
for _, flag := range SlidesXMLGet.Flags {
if flag.Name != "raw" {
continue
}
if !strings.Contains(flag.Desc, "formatted XML") || strings.Contains(flag.Desc, "raw XML") {
t.Fatalf("--raw description = %q, want formatted XML without a raw-payload claim", flag.Desc)
}
return
}
t.Fatal("--raw flag not found")
}
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
// Golden value computed independently of prettyPrintXML; see the comment
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
wantXML := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
@@ -244,8 +280,8 @@ func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
if err != nil {
t.Fatalf("read saved slide XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
if string(got) != wantXML {
t.Fatalf("saved XML = %q, want %q", got, wantXML)
}
data := decodeShortcutData(t, stdout)
if data["scope"] != "slide" {
@@ -263,6 +299,8 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
// The slide envelope carries the server content verbatim, like the
// presentation envelope.
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -305,11 +343,14 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
}
slide := data["slide"].(map[string]interface{})
if slide["content"] != xml {
t.Fatalf("content = %q, want %q", slide["content"], xml)
t.Fatalf("content = %q, want the server content verbatim %q", slide["content"], xml)
}
if slide["slide_id"] != "slide_2" {
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
@@ -515,3 +556,341 @@ func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
}
}
func TestPrettyPrintXML(t *testing.T) {
input := `<presentation id="p1" xmlns="http://www.larkoffice.com/sml/2.0" width="960"><slide id="s1"><style><fill id="f1"><fillColor color="rgba(0,0,0,1)"/></fill></style><data/></slide></presentation>`
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if !strings.Contains(got, "\n") {
t.Fatalf("expected reindented output with newlines, got %q", got)
}
if n := strings.Count(got, `xmlns="http://www.larkoffice.com/sml/2.0"`); n != 1 {
t.Fatalf("expected the xmlns declaration to appear exactly once, got %d occurrences in %q", n, got)
}
if !strings.Contains(got, "<data/>") {
t.Fatalf("expected empty <data/> to stay self-closing, got %q", got)
}
if !strings.Contains(got, `<fillColor color="rgba(0,0,0,1)"/>`) {
t.Fatalf("expected attributes to be preserved on their element, got %q", got)
}
}
func TestPrettyPrintXMLRejectsMalformedInput(t *testing.T) {
if _, err := prettyPrintXML(`<presentation><slide></presentation>`); err == nil {
t.Fatal("expected an error for malformed XML, got nil")
}
}
// TestPrettyPrintXMLPreservesEscapedWhitespaceReferences covers the schema's
// documented space/tab escape idiom (slides_xml_schema_definition.xml, <p>
// element docs) and CR/LF references whose lexical form is needed to avoid
// XML line-ending normalization on a later parse. An XML parser decodes the
// references into literal whitespace. The formatter must preserve their
// lexical representation for safe read-modify-write workflows.
func TestPrettyPrintXMLPreservesEscapedWhitespaceReferences(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"space in p", `<content><p>&#32;</p></content>`, "<content>\n <p>&#32;</p>\n</content>\n"},
{"tab in p", `<content><p>&#9;</p></content>`, "<content>\n <p>&#9;</p>\n</content>\n"},
{"space in nested span", `<content><p><span>&#32;</span></p></content>`, "<content>\n <p><span>&#32;</span></p>\n</content>\n"},
{"hex space", `<content><p>&#x20;</p></content>`, "<content>\n <p>&#x20;</p>\n</content>\n"},
{"zero-padded tab", `<content><p>&#0009;</p></content>`, "<content>\n <p>&#0009;</p>\n</content>\n"},
{"carriage return", `<content><p>A&#13;B</p></content>`, "<content>\n <p>A&#13;B</p>\n</content>\n"},
{"line feed", `<content><p>A&#10;B</p></content>`, "<content>\n <p>A&#10;B</p>\n</content>\n"},
{"hex carriage return", `<content><p>A&#xD;B</p></content>`, "<content>\n <p>A&#xD;B</p>\n</content>\n"},
{"hex line feed", `<content><p>A&#xA;B</p></content>`, "<content>\n <p>A&#xA;B</p>\n</content>\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestPrettyPrintXMLPreservesTextOnlyLeafWhitespace(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "title literal space",
input: `<presentation><title> </title><slide/></presentation>`,
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
},
{
name: "title escaped space",
input: `<presentation><title>&#32;</title><slide/></presentation>`,
want: "<presentation>\n <title>&#32;</title>\n <slide/>\n</presentation>\n",
},
{
name: "title whitespace CDATA",
input: `<presentation><title><![CDATA[ ]]></title><slide/></presentation>`,
want: "<presentation>\n <title><![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
},
{
name: "chart field literal space",
input: `<chartData><chartField name="x"> </chartField></chartData>`,
want: "<chartData>\n <chartField name=\"x\"> </chartField>\n</chartData>\n",
},
{
name: "title adjacent text and CDATA",
input: `<presentation><title> <![CDATA[ ]]></title><slide/></presentation>`,
want: "<presentation>\n <title> <![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings is the
// critical case: &#32; sitting as a bare sibling text node directly between
// two inline elements, not wrapped in its own tag -- the literal reading of
// the schema's "标签之间...请使用&#32;" guidance, e.g. a plain-styled space
// between two differently formatted words at a pptx run boundary. A fix
// that only special-cases "element whose sole content is whitespace" does
// not cover this: the whitespace here is one of several children of <p>,
// not the sole child of <span>.
func TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings(t *testing.T) {
input := `<content><p><span>Hello</span>&#32;<strong>World</strong></p></content>`
want := "<content>\n <p><span>Hello</span>&#32;<strong>World</strong></p>\n</content>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
func TestPrettyPrintXMLPreservesCDATA(t *testing.T) {
input := `<content><p><![CDATA[a-->b & <c>]]></p></content>`
want := "<content>\n <p><![CDATA[a-->b & <c>]]></p>\n</content>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
// TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText is the
// feature's actual point: a shape with many paragraphs becomes navigable
// (each <p> on its own indented line), while every paragraph's own rich
// text -- including an inline formatting boundary -- stays byte-for-byte
// unchanged.
func TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText(t *testing.T) {
input := `<content><p>First paragraph.</p><p>Second <strong>paragraph</strong>.</p></content>`
want := "<content>\n <p>First paragraph.</p>\n <p>Second <strong>paragraph</strong>.</p>\n</content>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
func TestPrettyPrintXMLIdempotent(t *testing.T) {
input := `<presentation><slide id="s1"><shape id="a"><content><p>A&#32;&#32;B&#9;C&#13;D&#10;E</p></content><style/></shape></slide></presentation>`
once, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML (first pass): %v", err)
}
twice, err := prettyPrintXML(once)
if err != nil {
t.Fatalf("prettyPrintXML (second pass): %v", err)
}
if once != twice {
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", once, twice)
}
}
func TestSlidesXMLGetFallsBackToOriginalPresentationWhenReformatFails(t *testing.T) {
content := "<presentation><title>\x0b</title><slide/></presentation>"
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--raw",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != content {
t.Fatalf("stdout = %q, want original content %q", got, content)
}
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
}
}
// TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent pins the
// envelope contract: the content is never parsed, so even malformed XML
// flows through byte for byte with no fallback warning and no
// pretty_printed field.
func TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent(t *testing.T) {
content := `<slide><data></slide>`
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide": map[string]interface{}{
"slide_id": "slide_1",
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
slide, _ := data["slide"].(map[string]interface{})
if slide == nil {
t.Fatalf("missing slide: %#v", data)
}
if got, _ := slide["content"].(string); got != content {
t.Fatalf("slide.content = %q, want the server content verbatim %q", got, content)
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
}
}
// TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent mirrors
// the slide-scope passthrough test for the presentation-scope fetch branch,
// which is a separate code path.
func TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent(t *testing.T) {
content := `<presentation><slide></presentation>`
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
presentation, _ := data["xml_presentation"].(map[string]interface{})
if presentation == nil {
t.Fatalf("missing xml_presentation: %#v", data)
}
if got, _ := presentation["content"].(string); got != content {
t.Fatalf("content = %q, want the server content verbatim %q", got, content)
}
if _, ok := data["pretty_printed"]; ok {
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
}
if got := stderr.String(); got != "" {
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
}
}
func TestSlidesXMLGetFileMetadataReportsPrettyPrintFallback(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
content := `<presentation><slide></presentation>`
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "fallback.xml",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, err := os.ReadFile(filepath.Join(dir, "fallback.xml"))
if err != nil {
t.Fatalf("read fallback XML: %v", err)
}
if string(got) != content {
t.Fatalf("saved XML = %q, want original content %q", got, content)
}
data := decodeShortcutData(t, stdout)
if data["pretty_printed"] != false {
t.Fatalf("pretty_printed = %v, want false", data["pretty_printed"])
}
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
}
}

View File

@@ -0,0 +1,260 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/xml"
"errors"
"io"
"slices"
"strings"
)
// textBearingTags are the SML elements whose schema content model is
// mixed (arbitrary text interleaved with inline markup): the <p> paragraph
// container and its inline formatting children, plus chart title/subtitle.
// See slides_xml_schema_definition.xml, <p> element docs: a deliberate space
// or tab is represented via &#32;/&#9; character references. Reindentation
// never descends into these elements; their entire subtree is copied
// verbatim from the input, so those references keep their exact spelling.
var textBearingTags = map[string]bool{
"p": true,
"strong": true,
"em": true,
"u": true,
"span": true,
"del": true,
"a": true,
"shadow": true,
"outline": true,
"chartTitle": true,
"chartSubTitle": true,
}
// tokenKind classifies a raw XML token for reindentation purposes.
type tokenKind uint8
const (
tokenStartElement tokenKind = iota // <name ...> or <name .../>
tokenEndElement // </name>, or zero-width after <name .../>
tokenCharData // text, character/entity references, or one CDATA section
tokenOther // comment, processing instruction, or directive
)
// rawToken records where one XML token lives inside the original input:
// input[start:end] is the token's exact source bytes. The decoded token
// value is deliberately discarded (only the element's local name is kept),
// which is the core invariant of this formatter: output can only ever be
// assembled from verbatim slices of the input, never from re-encoded data.
type rawToken struct {
kind tokenKind
start int // byte offset of the token's first source byte
end int // byte offset one past the token's last source byte
local string // local element name (namespace prefix stripped); start elements only
match int // start element: index of its matching end token; -1 otherwise
}
// tokenize runs encoding/xml over the whole input purely as a tokenizer and
// returns every token annotated with its raw byte range. Ranges come from
// Decoder.InputOffset, which counts bytes (multi-byte UTF-8 content cannot
// skew them), and consecutive tokens tile the input exactly, so slicing
// between them loses nothing.
//
// The full document is decoded before anything is emitted: any syntax error
// (mismatched or unclosed tags, invalid characters such as \x0b, undefined
// entities, bare ]]> in text, ...) fails the whole pretty-print, keeping the
// strict-parse behavior the fallback path in prettyPrintXMLOrOriginal
// depends on.
func tokenize(input string) ([]rawToken, error) {
decoder := xml.NewDecoder(strings.NewReader(input))
var tokens []rawToken
var openElements []int // indices into tokens of currently open start elements
pos := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
end := int(decoder.InputOffset())
raw := rawToken{start: pos, end: end, match: -1}
switch t := token.(type) {
case xml.StartElement:
raw.kind = tokenStartElement
raw.local = t.Name.Local
openElements = append(openElements, len(tokens))
case xml.EndElement:
// A strict decoder never emits an end element without its start
// element; guard anyway so a decoder change cannot panic here.
if len(openElements) == 0 {
return nil, errors.New("xml: unexpected end element")
}
raw.kind = tokenEndElement
startIndex := openElements[len(openElements)-1]
openElements = openElements[:len(openElements)-1]
tokens[startIndex].match = len(tokens)
case xml.CharData:
raw.kind = tokenCharData
default: // xml.Comment, xml.ProcInst, xml.Directive
raw.kind = tokenOther
}
tokens = append(tokens, raw)
pos = end
}
// A strict decoder reports unclosed elements as a syntax error before
// returning io.EOF; guard anyway so truncated output is impossible.
if len(openElements) != 0 {
return nil, errors.New("xml: unexpected EOF: unclosed element")
}
return tokens, nil
}
// prettyPrintXML reindents xmlContent so structural elements (presentation,
// slide, shape, style, ...) each sit on their own line. The server returns
// XML as a single unbroken line, and this is what makes the --raw and
// --output text surfaces readable; the JSON envelope path never calls it
// (see outputSlidesXMLGetContent).
//
// Offset-slicing invariant: encoding/xml serves purely as a tokenizer, and
// every byte of the output is either a verbatim slice of the input or an
// inserted "\n"+indent run between the children of a structural element.
// Nothing is parsed-and-reserialized, so CDATA sections, whitespace
// character references in any spelling (&#32;, &#x20;, &#0009;, &#13;,
// &#10;, ...), entity lexical forms, attribute quoting, and in-tag
// whitespace all survive byte-for-byte.
//
// Reindentation never enters a textBearingTags element and never touches a
// leaf element (one with no element children), so document text — including
// whitespace-only leaves such as <title> </title> — is never altered.
func prettyPrintXML(xmlContent string) (string, error) {
tokens, err := tokenize(xmlContent)
if err != nil {
return "", err
}
// The decoder tolerates element-free input (plain text, a lone comment,
// nothing at all). A document without a root element is not XML the
// formatter should claim success on; erroring routes it to the
// original-content fallback instead of reporting pretty_printed: true.
if !slices.ContainsFunc(tokens, func(t rawToken) bool { return t.kind == tokenStartElement }) {
return "", errors.New("xml: no root element")
}
var out strings.Builder
out.Grow(len(xmlContent) + len(xmlContent)/8)
reindented := false
for i := 0; i < len(tokens); {
token := tokens[i]
if token.kind == tokenStartElement {
if reindented {
// Any top-level element after the first is copied verbatim;
// well-formed XML has a single root, so this arm only runs
// on technically invalid multi-root input the decoder
// happens to tolerate.
out.WriteString(xmlContent[token.start:tokens[token.match].end])
} else {
writeElement(&out, xmlContent, tokens, i, 0)
reindented = true
}
i = token.match + 1
continue
}
// Document-level prolog and epilog (XML declaration, DOCTYPE,
// comments, whitespace) pass through verbatim.
out.WriteString(xmlContent[token.start:token.end])
i++
}
formatted := out.String()
if !strings.HasSuffix(formatted, "\n") {
formatted += "\n"
}
return formatted, nil
}
// writeElement emits the element whose start token is tokens[startIndex],
// indented as if at the given depth (two spaces per level).
//
// Text-bearing elements and leaf elements (no element children) are emitted
// as a single verbatim input slice from open tag through close tag; for a
// self-closing tag the synthesized end token is zero-width and the slice is
// exactly the open tag. Structural elements (at least one element child,
// not text-bearing) are reindented: text children that are pure literal
// whitespace are dropped as pre-existing formatting, "\n"+indent is
// inserted before every element, comment, and processing-instruction child,
// kept text children stay glued in place with no indentation around them,
// and the close tag moves to its own line unless the last kept child is
// text.
//
// The whitespace-only test runs on the child's RAW source bytes: a
// character reference (&#32;) or a CDATA section is not literal whitespace
// there, so it is kept and its lexical form survives.
func writeElement(out *strings.Builder, input string, tokens []rawToken, startIndex, depth int) {
start := tokens[startIndex]
end := tokens[start.match]
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
out.WriteString(input[start.start:end.end])
return
}
out.WriteString(input[start.start:start.end])
childIndent := "\n" + strings.Repeat(" ", depth+1)
lastKeptIsText := false
for i := startIndex + 1; i < start.match; {
child := tokens[i]
switch child.kind {
case tokenCharData:
if !isAllWhitespace(input[child.start:child.end]) {
out.WriteString(input[child.start:child.end])
lastKeptIsText = true
}
i++
case tokenStartElement:
out.WriteString(childIndent)
writeElement(out, input, tokens, i, depth+1)
lastKeptIsText = false
i = child.match + 1
default: // comment, processing instruction, directive
out.WriteString(childIndent)
out.WriteString(input[child.start:child.end])
lastKeptIsText = false
i++
}
}
if !lastKeptIsText {
out.WriteString("\n")
out.WriteString(strings.Repeat(" ", depth))
}
out.WriteString(input[end.start:end.end])
}
// hasElementChild reports whether the element starting at tokens[startIndex]
// has at least one direct element child. The first start-element token that
// appears before the matching end token is necessarily a direct child, so a
// linear scan without depth tracking suffices.
func hasElementChild(tokens []rawToken, startIndex int) bool {
for i := startIndex + 1; i < tokens[startIndex].match; i++ {
if tokens[i].kind == tokenStartElement {
return true
}
}
return false
}
// isAllWhitespace reports whether s is non-empty and consists only of
// literal XML whitespace bytes (space, tab, CR, LF). It is applied to raw
// source bytes, where character references and CDATA markers count as
// non-whitespace by construction.
func isAllWhitespace(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
switch s[i] {
case ' ', '\t', '\n', '\r':
default:
return false
}
}
return true
}

View File

@@ -0,0 +1,416 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"os"
"strings"
"testing"
)
// The pure-function contract tests for prettyPrintXML (golden strings,
// whitespace character references, leaf whitespace, CDATA, idempotency,
// malformed rejection) live in slides_xml_get_test.go, unchanged from the
// original etree-based implementation. This file adds engine-level cases
// specific to the offset-slicing implementation.
func TestPrettyPrintXMLGoldenPresentation(t *testing.T) {
input := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
want := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
func TestPrettyPrintXMLGoldenSlide(t *testing.T) {
input := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
want := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
got, err := prettyPrintXML(input)
if err != nil {
t.Fatalf("prettyPrintXML: %v", err)
}
if got != want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
}
}
// TestPrettyPrintXMLRejectsMalformedInputTable pins that the whole document
// is decoded before anything is emitted: even a late syntax error yields no
// partial output, only the error the fallback path reports.
func TestPrettyPrintXMLRejectsMalformedInputTable(t *testing.T) {
tests := []struct {
name string
input string
}{
{"mismatched close tag", `<presentation><slide></presentation>`},
{"unclosed slide from fallback test", `<slide><data></slide>`},
{"invalid control character", "<presentation><title>\x0b</title><slide/></presentation>"},
{"unclosed root", `<presentation><slide/>`},
{"undefined entity", `<presentation><title>&nbsp;</title></presentation>`},
{"bare close tag", `</presentation>`},
{"unescaped cdata terminator in text", `<presentation><title>a]]>b</title></presentation>`},
{"late error after valid prefix", `<presentation><slide/><slide/><slide id=></presentation>`},
{"empty input", ``},
{"whitespace-only input", ` `},
{"plain text without markup", `hello`},
{"comment-only document", `<!-- only a comment -->`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err == nil {
t.Fatalf("prettyPrintXML(%q) = %q, want error", tt.input, got)
}
if got != "" {
t.Fatalf("prettyPrintXML(%q) returned partial output %q alongside error %v", tt.input, got, err)
}
})
}
}
// TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText pins that user content
// resembling the previous implementation's masking placeholders
// (LARKCLI_XML_WHITESPACE_REFERENCE_<n>_) flows through untouched now that
// no masking exists at all.
func TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "placeholder-shaped text in p",
input: `<content><p>LARKCLI_XML_WHITESPACE_REFERENCE_0_&#32;end</p></content>`,
want: "<content>\n <p>LARKCLI_XML_WHITESPACE_REFERENCE_0_&#32;end</p>\n</content>\n",
},
{
name: "placeholder-shaped text in leaf",
input: `<presentation><title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title><slide/></presentation>`,
want: "<presentation>\n <title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title>\n <slide/>\n</presentation>\n",
},
{
name: "placeholder-shaped attribute value",
input: `<presentation><slide note="LARKCLI_XML_WHITESPACE_REFERENCE_0_"><shape/></slide></presentation>`,
want: "<presentation>\n <slide note=\"LARKCLI_XML_WHITESPACE_REFERENCE_0_\">\n <shape/>\n </slide>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestPrettyPrintXMLStructuralTable covers comments, processing
// instructions, prolog/DOCTYPE, mixed text between structural children,
// CRLF pre-formatting, and multi-byte UTF-8 around offset boundaries.
// Expected outputs were verified byte-identical against the previous
// etree-based implementation via a differential probe.
func TestPrettyPrintXMLStructuralTable(t *testing.T) {
tests := []struct {
name string
input string
want string
// wantSecond is the expected output of formatting the output again.
// Usually equal to want (idempotent); the mixed-content rows pin the
// one known non-idempotent shape, where kept text merges with the
// inserted indent on reparse — byte-identical to the previous
// implementation's behavior on the same inputs. Real SML structural
// elements carry no mixed text, so the contract's idempotency
// guarantee is unaffected.
wantSecond string
}{
{
name: "comment child is indented like an element",
input: `<presentation><!-- deck notes --><slide/></presentation>`,
want: "<presentation>\n <!-- deck notes -->\n <slide/>\n</presentation>\n",
},
{
name: "processing instruction child is indented like an element",
input: `<presentation><?pi data?><slide/></presentation>`,
want: "<presentation>\n <?pi data?>\n <slide/>\n</presentation>\n",
},
{
name: "xml declaration prolog stays glued to the root",
input: `<?xml version="1.0" encoding="UTF-8"?><presentation><slide/></presentation>`,
want: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><presentation>\n <slide/>\n</presentation>\n",
},
{
name: "prolog with doctype and trailing newline preserved verbatim",
input: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation><slide/></presentation>\n",
want: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation>\n <slide/>\n</presentation>\n",
},
{
name: "document-level trailing comment preserved verbatim",
input: "<presentation><slide/></presentation><!-- tail -->",
want: "<presentation>\n <slide/>\n</presentation><!-- tail -->\n",
},
{
name: "kept mixed text glues to previous sibling and close tag",
input: `<data>x<child/>y</data>`,
want: "<data>x\n <child/>y</data>\n",
wantSecond: "<data>x\n \n <child/>y</data>\n",
},
{
name: "kept mixed text does not suppress indent of next element",
input: `<data>x<child/>y<child/></data>`,
want: "<data>x\n <child/>y\n <child/>\n</data>\n",
wantSecond: "<data>x\n \n <child/>y\n \n <child/>\n</data>\n",
},
{
name: "pre-existing CRLF formatting is dropped and rebuilt",
input: "<presentation>\r\n\t<slide/>\r\n</presentation>",
want: "<presentation>\n <slide/>\n</presentation>\n",
},
{
name: "multi-byte UTF-8 text and attributes keep exact bytes",
input: `<presentation><title>原生图表 📊 Chart</title><slide 备注="中文värde"><shape/></slide></presentation>`,
want: "<presentation>\n <title>原生图表 📊 Chart</title>\n <slide 备注=\"中文värde\">\n <shape/>\n </slide>\n</presentation>\n",
},
{
name: "namespace-prefixed p is still text-bearing",
input: `<content xmlns:sml="urn:x"><sml:p><span>a</span>&#32;<span>b</span></sml:p></content>`,
want: "<content xmlns:sml=\"urn:x\">\n <sml:p><span>a</span>&#32;<span>b</span></sml:p>\n</content>\n",
},
{
name: "already formatted input is preserved",
input: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
want: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
wantSecond := tt.wantSecond
if wantSecond == "" {
wantSecond = tt.want
}
again, err := prettyPrintXML(got)
if err != nil {
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
}
if again != wantSecond {
t.Fatalf("second pass:\nonce: %q\ntwice: %q\nwant: %q", got, again, wantSecond)
}
})
}
}
// TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged pins the cases where
// slicing original bytes intentionally differs from the previous
// etree-based parse-and-reserialize implementation. Each case preserves the
// input MORE faithfully than before; none is covered by the original
// contract tests. The etree field records the old output for the record.
func TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged(t *testing.T) {
tests := []struct {
name string
input string
want string // current behavior: original bytes preserved
etree string // what the etree-based implementation produced
}{
{
name: "whitespace-only CDATA between structural children is kept",
input: `<data><![CDATA[ ]]><child/></data>`,
want: "<data><![CDATA[ ]]>\n <child/>\n</data>\n",
etree: "<data>\n <child/>\n</data>\n",
},
{
name: "empty element with explicit close tag is not collapsed",
input: `<slide><data></data><shape/></slide>`,
want: "<slide>\n <data></data>\n <shape/>\n</slide>\n",
etree: "<slide>\n <data/>\n <shape/>\n</slide>\n",
},
{
name: "non-whitespace character reference keeps its lexical form",
input: `<presentation><title>&#65;&amp;&#x4E2D;</title><slide/></presentation>`,
want: "<presentation>\n <title>&#65;&amp;&#x4E2D;</title>\n <slide/>\n</presentation>\n",
etree: "<presentation>\n <title>A&amp;中</title>\n <slide/>\n</presentation>\n",
},
{
name: "single-quoted attributes keep their quoting",
input: `<presentation><slide id='s1'><shape/></slide></presentation>`,
want: "<presentation>\n <slide id='s1'>\n <shape/>\n </slide>\n</presentation>\n",
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
},
{
name: "in-tag whitespace is preserved verbatim",
input: "<presentation><slide id=\"s1\" ><shape/></slide ></presentation>",
want: "<presentation>\n <slide id=\"s1\" >\n <shape/>\n </slide >\n</presentation>\n",
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
},
{
name: "literal > in leaf text is not re-escaped",
input: `<presentation><title>a>b</title><slide/></presentation>`,
want: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
etree: "<presentation>\n <title>a&gt;b</title>\n <slide/>\n</presentation>\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prettyPrintXML(tt.input)
if err != nil {
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
}
if tt.want == tt.etree {
t.Fatalf("case is not a divergence: want == etree == %q", tt.want)
}
again, err := prettyPrintXML(got)
if err != nil {
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
}
if again != got {
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", got, again)
}
})
}
}
// loadChartDemo reads the real-world chart demo shipped with the
// lark-slides skill (~60KB, pretty-printed): the closest in-repo stand-in
// for a full presentation read.
func loadChartDemo(t testing.TB) string {
t.Helper()
data, err := os.ReadFile("../../skills/lark-slides/references/slides_chart_demo.xml")
if err != nil {
t.Fatalf("read chart demo fixture: %v", err)
}
return string(data)
}
// minifyXML strips whitespace-only text children of structural (non
// text-bearing, element-bearing) elements — the exact text nodes
// prettyPrintXML treats as disposable formatting — producing the
// single-line element shape the slides server actually returns.
// Document-level tokens (prolog, trailing newline) pass through verbatim,
// because the formatter preserves them verbatim too.
func minifyXML(t testing.TB, input string) string {
t.Helper()
tokens, err := tokenize(input)
if err != nil {
t.Fatalf("tokenize for minify: %v", err)
}
var out strings.Builder
var emitElement func(startIndex int)
emitElement = func(startIndex int) {
start := tokens[startIndex]
end := tokens[start.match]
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
out.WriteString(input[start.start:end.end])
return
}
out.WriteString(input[start.start:start.end])
for i := startIndex + 1; i < start.match; {
child := tokens[i]
switch child.kind {
case tokenCharData:
if !isAllWhitespace(input[child.start:child.end]) {
out.WriteString(input[child.start:child.end])
}
i++
case tokenStartElement:
emitElement(i)
i = child.match + 1
default:
out.WriteString(input[child.start:child.end])
i++
}
}
out.WriteString(input[end.start:end.end])
}
for i := 0; i < len(tokens); {
token := tokens[i]
if token.kind == tokenStartElement {
emitElement(i)
i = token.match + 1
continue
}
out.WriteString(input[token.start:token.end])
i++
}
return out.String()
}
// TestPrettyPrintXMLChartDemoFixture formats the real chart demo both as
// shipped (pretty-printed) and minified to the single-line shape the server
// returns; both must converge on the same idempotent output.
func TestPrettyPrintXMLChartDemoFixture(t *testing.T) {
original := loadChartDemo(t)
formattedOriginal, err := prettyPrintXML(original)
if err != nil {
t.Fatalf("prettyPrintXML(original): %v", err)
}
twice, err := prettyPrintXML(formattedOriginal)
if err != nil {
t.Fatalf("prettyPrintXML(second pass): %v", err)
}
if twice != formattedOriginal {
t.Fatal("prettyPrintXML is not idempotent on the chart demo fixture")
}
minified := minifyXML(t, original)
if strings.Contains(minified, ">\n <") {
t.Fatalf("minified fixture still contains structural indentation: %q", minified[:200])
}
// Only the doc-level newline after the XML declaration and the trailing
// newline may remain; the whole element tree must be one line.
if got := strings.Count(minified, "\n"); got > 2 {
t.Fatalf("minified fixture has %d newlines, want <= 2", got)
}
formattedMinified, err := prettyPrintXML(minified)
if err != nil {
t.Fatalf("prettyPrintXML(minified): %v", err)
}
// Formatting drops exactly the whitespace minification dropped, so both
// paths must converge on the same output.
if formattedMinified != formattedOriginal {
t.Fatal("format(minified) != format(original) for the chart demo fixture")
}
if !strings.Contains(formattedMinified, "\n <slide>") {
t.Fatal("formatted chart demo lacks expected slide indentation")
}
}
func BenchmarkPrettyPrintXMLChartDemoMinified(b *testing.B) {
minified := minifyXML(b, loadChartDemo(b))
b.SetBytes(int64(len(minified)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := prettyPrintXML(minified); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkPrettyPrintXMLChartDemoPreformatted(b *testing.B) {
original := loadChartDemo(b)
b.SetBytes(int64(len(original)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := prettyPrintXML(original); err != nil {
b.Fatal(err)
}
}
}

View File

@@ -10,7 +10,6 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -101,40 +100,6 @@ func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
func parseTaskGUID(input string) (string, error) {
input = strings.TrimSpace(input)
invalid := func(format string, args ...interface{}) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
WithParam("--task-id").
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
}
if input == "" {
return "", invalid("task ID is empty")
}
lowerInput := strings.ToLower(input)
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
u, err := url.Parse(input)
if err != nil {
return "", invalid("invalid task applink: %v", err).WithCause(err)
}
guid := strings.TrimSpace(u.Query().Get("guid"))
if guid == "" {
return "", invalid("task applink is missing a non-empty guid query parameter")
}
return guid, nil
}
if taskDisplayNumberPattern.MatchString(input) {
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
}
return input, nil
}
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})

View File

@@ -4,11 +4,8 @@
package task
import (
"errors"
"net/url"
"testing"
"github.com/larksuite/cli/errs"
"github.com/smartystreets/goconvey/convey"
)
@@ -18,80 +15,3 @@ func TestShortcutsRegistration(t *testing.T) {
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}
func TestParseTaskGUID(t *testing.T) {
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
{
name: "task applink",
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
want: "task-guid-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTaskGUID(tt.input)
if err != nil {
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
})
t.Run("rejects unusable task identifiers", func(t *testing.T) {
for _, input := range []string{
"",
"https://applink.larksuite.com/client/todo/detail",
"https://%",
"t12345",
} {
t.Run(input, func(t *testing.T) {
_, err := parseTaskGUID(input)
if err == nil {
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
if problem.Hint == "" {
t.Fatal("problem hint is empty")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--task-id" {
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
}
})
}
})
t.Run("preserves applink parse cause", func(t *testing.T) {
_, err := parseTaskGUID("https://%")
if err == nil {
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
}
var urlErr *url.Error
if !errors.As(err, &urlErr) {
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
}
})
}

View File

@@ -25,59 +25,45 @@ var CompleteTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUID(runtime.Str("task-id"))
return err
{Name: "task-id", Desc: "task id", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCompleteBody()
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskID := url.PathEscape(taskGUID)
taskId := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
GET("/open-apis/task/v2/tasks/" + taskID).
GET("/open-apis/task/v2/tasks/" + taskId).
Desc("get current task status").
Params(map[string]interface{}{"user_id_type": "open_id"}).
PATCH("/open-apis/task/v2/tasks/" + taskID).
PATCH("/open-apis/task/v2/tasks/" + taskId).
Desc("complete task if not completed").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return err
}
taskID := url.PathEscape(taskGUID)
taskId := url.PathEscape(runtime.Str("task-id"))
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
completedAtStr, _ := taskData["completed_at"].(string)
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
// 2. If already completed, directly return success
if alreadyCompleted {
if completedAtStr != "" && completedAtStr != "0" {
data = getData
} else {
// 3. Complete the task
body := buildCompleteBody()
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}
@@ -87,19 +73,11 @@ var CompleteTask = common.Shortcut{
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
completedAt, _ := task["completed_at"].(string)
status := "todo"
if completedAt != "" && completedAt != "0" {
status = "done"
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
"status": status,
"completed_at": completedAt,
"already_completed": alreadyCompleted,
"guid": guid,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {

View File

@@ -4,12 +4,9 @@
package task
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -48,9 +45,6 @@ func TestCompleteTask(t *testing.T) {
formatFlag: "json",
expectedOutput: []string{
`"guid": "task-789"`,
`"status": "done"`,
`"completed_at": "1775174400000"`,
`"already_completed": false`,
},
},
}
@@ -115,98 +109,3 @@ func TestCompleteTask(t *testing.T) {
})
}
}
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
for _, method := range []string{"GET", "PATCH"} {
reg.Register(&httpmock.Stub{
Method: method,
URL: "/open-apis/task/v2/tasks/task-guid-applink",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-applink",
"summary": "Applink task",
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
"url": "https://example.com/task-guid-applink",
},
},
},
})
}
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete",
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
}
}
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-guid-done",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-done",
"summary": "Already done",
"completed_at": "1775174400000",
"url": "https://example.com/task-guid-done",
},
},
},
})
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
}
}
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("CompleteTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}

View File

@@ -24,14 +24,6 @@ func splitAndTrimCSV(input string) []string {
return out
}
func buildSearchPageParams(pageToken string) map[string]interface{} {
params := map[string]interface{}{}
if pageToken != "" {
params["page_token"] = pageToken
}
return params
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil

View File

@@ -37,31 +37,6 @@ func TestSplitAndTrimCSV(t *testing.T) {
}
}
func TestBuildSearchPageParams(t *testing.T) {
tests := []struct {
name string
pageToken string
wantToken string
wantKey bool
}{
{name: "first page omits token"},
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := buildSearchPageParams(tt.pageToken)
got, present := params["page_token"]
if present != tt.wantKey {
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
}
if tt.wantKey && got != tt.wantToken {
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string

View File

@@ -44,10 +44,8 @@ var SearchTask = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
@@ -76,9 +74,9 @@ var SearchTask = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
params := buildSearchPageParams(runtime.Str("page-token"))
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
if err != nil {
return err
}
@@ -92,7 +90,7 @@ var SearchTask = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
params["page_token"] = lastPageToken
currentBody["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
@@ -185,6 +183,9 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"io"
"net/http"
"reflect"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSearchPaginationUsesQueryToken(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
command string
url string
}{
{
name: "tasks",
shortcut: SearchTask,
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
shortcut: SearchTasklist,
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
var pageTokens []string
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
shortcut := tt.shortcut
shortcut.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, shortcut, []string{
tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--page-limit", "2",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("search command failed: %v", err)
}
want := []string{"initial_pt", "next_pt"}
if !reflect.DeepEqual(pageTokens, want) {
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
}
})
}
}
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
t.Helper()
data, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal search dry-run preview: %v", err)
}
var envelope struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode search dry-run preview: %v", err)
}
if len(envelope.API) != 1 {
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
}
call := envelope.API[0]
if got, _ := call.Params["page_token"].(string); got != want {
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
}
if _, present := call.Body["page_token"]; present {
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
}
}
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
t.Helper()
return &httpmock.Stub{
Method: http.MethodPost,
URL: endpoint,
OnMatch: func(req *http.Request) {
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("read search request body: %v", err)
return
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("decode search request body: %v", err)
return
}
if _, present := payload["page_token"]; present {
t.Errorf("search request body unexpectedly contains page_token: %s", body)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": hasMore,
"page_token": responseToken,
"items": []interface{}{},
},
},
}
}

View File

@@ -37,12 +37,9 @@ func TestBuildTaskSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" {
if body["query"] != "release" || body["page_token"] != "pt_123" {
t.Fatalf("unexpected body: %#v", body)
}
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
@@ -107,10 +104,9 @@ func TestBuildTaskSearchBody(t *testing.T) {
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
@@ -118,8 +114,7 @@ func TestSearchTask_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantPageToken: "pt_demo",
wantParts: []string{`"query":"demo"`},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
},
{
name: "dry run error on invalid due",
@@ -148,11 +143,7 @@ func TestSearchTask_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
preview := SearchTask.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
out := SearchTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -41,10 +41,8 @@ var SearchTasklist = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
@@ -73,9 +71,9 @@ var SearchTasklist = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
params := buildSearchPageParams(runtime.Str("page-token"))
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
if err != nil {
return err
}
@@ -89,7 +87,7 @@ var SearchTasklist = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
params["page_token"] = lastPageToken
currentBody["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
@@ -172,6 +170,9 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
@@ -80,10 +80,9 @@ func TestBuildTasklistSearchBody(t *testing.T) {
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
@@ -91,8 +90,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantPageToken: "pt_tl",
wantParts: []string{`"query":"Q2"`},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
@@ -118,11 +116,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
preview := SearchTasklist.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
out := SearchTasklist.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -27,42 +27,27 @@ var UpdateTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "data", Desc: "JSON payload for task object"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUIDs(runtime.Str("task-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
preview := common.NewDryRunAPI()
for _, taskID := range taskIDs {
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return preview
taskIds := strings.Split(runtime.Str("task-id"), ",")
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
return common.NewDryRunAPI().
PATCH("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return err
}
body, err := buildTaskUpdateBody(runtime)
if err != nil {
// buildTaskUpdateBody already returns a typed validation error;
@@ -70,11 +55,17 @@ var UpdateTask = common.Shortcut{
return err
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
var updatedTasks []map[string]interface{}
for _, taskID := range taskIDs {
for _, taskId := range taskIds {
taskId = strings.TrimSpace(taskId)
if taskId == "" {
continue
}
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
if err != nil {
return err
}
@@ -85,28 +76,19 @@ var UpdateTask = common.Shortcut{
}
}
updateFields, _ := body["update_fields"].([]string)
var tasks []map[string]interface{}
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
confirmed := make(map[string]interface{})
for _, field := range updateFields {
if value, ok := task[field]; ok {
confirmed[field] = value
}
}
tasks = append(tasks, map[string]interface{}{
"guid": guid,
"url": urlVal,
"confirmed": confirmed,
"guid": guid,
"url": urlVal,
})
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"updated_fields": updateFields,
"tasks": tasks,
"tasks": tasks,
}
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
@@ -130,26 +112,6 @@ var UpdateTask = common.Shortcut{
},
}
func parseTaskGUIDs(input string) ([]string, error) {
parts := strings.Split(input, ",")
taskGUIDs := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
guid, err := parseTaskGUID(part)
if err != nil {
return nil, err
}
taskGUIDs = append(taskGUIDs, guid)
}
if len(taskGUIDs) == 0 {
_, err := parseTaskGUID("")
return nil, err
}
return taskGUIDs, nil
}
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
taskObj := make(map[string]interface{})
var updateFields []string

View File

@@ -1,201 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestParseTaskGUIDs(t *testing.T) {
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
if err != nil {
t.Fatalf("parseTaskGUIDs() error = %v", err)
}
want := []string{"task-guid-1", "task-guid-2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
}
_, err = parseTaskGUIDs("task-guid-1,t12345")
if err == nil {
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
}
}
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
cmd.Flags().String("summary", "updated", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("data", "", "")
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
payload, err := json.Marshal(preview)
if err != nil {
t.Fatalf("marshal dry-run preview: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("decode dry-run preview: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
}
wantURLs := []string{
"/open-apis/task/v2/tasks/task-guid-1",
"/open-apis/task/v2/tasks/task-guid-2",
}
for i, call := range got.API {
if call.Method != "PATCH" {
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
}
if call.URL != wantURLs[i] {
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
}
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
t.Errorf("api[%d].params = %#v", i, call.Params)
}
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
}
}
}
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
first := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-1",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-1",
"url": "https://example.com/task-guid-1",
"summary": "server summary one",
"description": "server description one",
},
},
},
}
second := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-2",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-2",
"url": "https://example.com/task-guid-2",
"summary": "server summary two",
},
},
},
}
reg.Register(first)
reg.Register(second)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
"--summary", "requested summary",
"--description", "requested description",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("UpdateTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", envelope["data"])
}
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
t.Fatalf("updated_fields = %v, want [summary description]", got)
}
tasks, ok := data["tasks"].([]interface{})
if !ok || len(tasks) != 2 {
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
}
firstTask := tasks[0].(map[string]interface{})
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
t.Fatalf("first task identifiers = %#v", firstTask)
}
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary one", "description": "server description one",
}) {
t.Fatalf("first confirmed = %#v", got)
}
secondTask := tasks[1].(map[string]interface{})
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary two",
}) {
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
}
}
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,t12345",
"--summary", "must not be written",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("UpdateTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}
func stringSlice(value interface{}) []string {
items, _ := value.([]interface{})
result := make([]string, 0, len(items))
for _, item := range items {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}

View File

@@ -28,7 +28,7 @@
## 各命令
### +file-list
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
```bash
lark-cli apps +file-list --app-id app_xxx

View File

@@ -29,7 +29,7 @@ metadata:
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL简单命令由命令自身的参数、tips 和错误恢复承接
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
@@ -104,18 +104,19 @@ metadata:
## 写入前置规则
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新、表单提交(`+form-submit`等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在。
## 表单与视图细节
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。

View File

@@ -4,8 +4,6 @@
通过表单分享链接填写并提交多维表格表单。仅支持分享模式share_token支持填写普通字段值和上传本地文件作为附件。
> **⚠️ 高风险写操作high-risk-write** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
## 填写前必读:先获取表单详情
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
@@ -23,11 +21,10 @@ lark-cli base +form-detail --share-token <share_token>
# 2⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
# 3⃣ 再提交(高风险写操作,必须带 --yes
# 3⃣ 再提交
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--yes
--json '{"fields":{...}}'
```
`+form-detail` 的返回中要重点读取 `questions[].type``questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`
@@ -38,8 +35,7 @@ lark-cli base +form-submit \
# 基本提交(填写普通字段)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
--yes
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
# 带附件提交(需要额外提供 --base-token
lark-cli base +form-submit \
@@ -51,17 +47,15 @@ lark-cli base +form-submit \
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}' \
--yes
}'
# 使用应用身份bot
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--as bot \
--yes
--as bot
# 预览 API 调用(不实际执行dry-run 无需 --yes
# 预览 API 调用(不实际执行)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
@@ -75,7 +69,6 @@ lark-cli base +form-submit \
| `--share-token <token>` | 是 | 表单分享 Token必填从表单分享链接中提取 |
| `--base-token <token>` | 条件必填 | Base token**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write不带 `--yes` 会返回 `confirmation_required` |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
@@ -145,8 +138,7 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```bash
lark-cli base +form-submit \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
--json '{"fields":{...}}' \
--yes
--json '{"fields":{...}}'
```
## 输出格式
@@ -166,7 +158,6 @@ lark-cli base +form-submit \
## 提示
- **本命令为高风险写操作high-risk-write必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
- 本命令仅支持通过表单分享链接share_token提交不支持通过 base_token + table_id + view_id 方式提交
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
- 附件字段只需在 `--json.attachments` 中提供本地路径即可CLI 自动完成校验、并行上传、Token 获取和合并写入

View File

@@ -96,7 +96,6 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
每轮扩展都要保留非污染、可解释的 evidenceURL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
扩展 query 时,优先保留用户已经指定的空间、文件夹、群聊、人员、时间和类型等 filter确需放宽检索范围时先向用户说明原因并征得确认。
## 参数

View File

@@ -29,7 +29,7 @@ Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声
"title": { "tag": "plain_text", "content": "卡片标题" },
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "lark-logo_colorful" },
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
]

View File

@@ -105,12 +105,12 @@
"header": {
"title": { "tag": "plain_text", "content": "卡片标题" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "calendar_colorful" }
"icon": { "tag": "standard_icon", "token": "mail_colorful" }
}
```
- `token` 必须`resource/icons.md` 的精确枚举中选择;禁止根据名称规律自行拼接 token。没有合适的 token 时省略 icon
- 场景速查:日历 `calendar_colorful`、待办 `todo_colorful`、投票 `vote_colorful`妙记 `file-lark-minutes_colorful`、多维表格 `wiki-bitable_colorful`、表单 `file-form_colorful`社区 `larkcommunity_colorful`、招聘 `hirelogo_colorful`、飞书品牌 `lark-logo_colorful`、Meego `meego_colorful`、AI `myai_colorful`、aPaaS `apaas_colorful`、审批 `approval_colorful`通用 AI `ai-common_colorful`
- `token``resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称
- 常用速查:通知 `notice_colorful`告警 `warning_colorful`、审批 `approve_colorful`日历 `calendar_colorful`、数据 `chart_colorful`、任务 `todo_colorful`、AI `myai_colorful`
### 1. 配色纪律(服务 P6 语义一致)
@@ -212,7 +212,7 @@ header 有三层能力,**尽量用满**(至少用 `title` + `icon``subtit
"title": { "tag": "plain_text", "content": "发版审批" },
"subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" },
"template": "blue",
"icon": { "tag": "standard_icon", "token": "approval_colorful" },
"icon": { "tag": "standard_icon", "token": "approve_colorful" },
"text_tag_list": [
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" }
]

View File

@@ -34,19 +34,5 @@
| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` |
| 附件 | `attachment_outlined` | 审批 | `approval_outlined` |
## 彩色图标(精确 token
彩色图标必须从下表按**完整字符串**选择,禁止根据名称规律自行拼接。彩色 token 自带颜色,不要再推导其他后缀或变体。
| 含义 | token | 含义 | token |
|---|---|---|---|
| 日历 | `calendar_colorful` | 待办 | `todo_colorful` |
| 投票 | `vote_colorful` | 飞书妙记 | `file-lark-minutes_colorful` |
| 多维表格 | `wiki-bitable_colorful` | 表单 | `file-form_colorful` |
| 飞书社区 | `larkcommunity_colorful` | 招聘 | `hirelogo_colorful` |
| 飞书品牌 | `lark-logo_colorful` | Meego | `meego_colorful` |
| AI | `myai_colorful` | aPaaS | `apaas_colorful` |
| 审批 | `approval_colorful` | 通用 AI | `ai-common_colorful` |
> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准:
> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons

View File

@@ -101,9 +101,9 @@ metadata:
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口`summary.warning_count > 0` 时必须先做对应页面的截图复核**
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)`summary.error_count` 必须为 0 才能调用接口。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
## 命令

View File

@@ -19,7 +19,7 @@
2.`slides +xml-get` 回读,确认是否已有部分页面写入。
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 `xml_text_overlap_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。

View File

@@ -25,32 +25,19 @@ lark-cli slides +xml-get --as user \
--json
```
## Automated XML Layout Lint
## Automated XML Text Overlap Lint
`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
```bash
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
```
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、线条穿字、文本越出候选卡片、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
通过标准:
准出规则:
- `summary.error_count > 0``summary.release_ready == false`:阻断创建、替换或交付,必须先修复
- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。
- `slides[].status``blocked``needs_screenshot_review``passed`,可直接决定逐页后续动作。
- CLI 在存在 `error` 时退出码为 1只有 `warning` 时仍输出 JSON 并退出 0供截图复核链路继续执行。
每条 `error` / `warning` 都包含:
- `element_ids`:相关 XML 元素 ID
- `rule`:规则 ID、名称、阈值和比较关系
- `measurement`:越界量、交叠面积、覆盖率等实测值;
- `related_objects`:相关对象的类型与坐标框;
- `target``message``hint`:页码、语义说明和处理建议。
`sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收
常见 code 的处理方向:
@@ -64,12 +51,6 @@ python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <pres
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
| `icon_transparent_fill_color` | `<icon>``fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
| `line_crosses_text` | 线条与非空文本框的声明边界相交 | 结合截图确认是否为有意删除线;否则缩短/移动线条或移动文本 |
| `text_outside_container` | 文本框属于候选矩形卡片,且估算的实际文字区域触碰或越过卡片底边 | 结合截图确认卡片归属与实际字形;增高卡片、上移文本或调整文字排版 |
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |
## Screenshot QA

View File

@@ -188,13 +188,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<shadow>`
- `<content>`
`type` 常用取值:`text`(文本框)、`rect``round-rect`(圆角矩形)、`ellipse`(椭圆/圆)、`triangle``diamond``parallelogram``trapezoid``custom`(配合 `path` 属性写 SVG 路径串)。箭头、星形、标注气泡、`chevron``flow-chart-*` 等更多形状见 XSD `ShapeType` 枚举。
其它可选属性:
- `presetHandlers`:控制点,用于圆角等。例如 `<shape type="rect" presetHandlers="60">` = 圆角半径 60px 的圆角矩形;多个控制点用逗号分隔。
- `path`:仅 `type="custom"` 时使用SVG 路径串。
### line
```xml
@@ -205,16 +198,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`
### polyline
折线 / 曲线连接线,用外接矩形定位(`topLeftX` / `topLeftY` / `width` / `height`),不是端点坐标;`<border>` 必填(无 border 不可见)。`type` 默认 `bent-connector2`(可选 `bent-connector2-5` 折线 / `curved-connector2-5` 曲线)。
```xml
<polyline topLeftX="120" topLeftY="120" width="200" height="100">
<border color="rgb(43, 47, 54)" width="2"/>
</polyline>
```
### img
```xml
@@ -255,7 +238,6 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<colgroup>` 直接子元素只有 `<col width="...">`width 定义列宽,默认 110。
- `<tr height="...">` 直接子元素只有 `<td>`height 定义行高,默认 37。
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>``<img>``<icon>`
- 合并单元格:`<td>` 上用 `colspan`(跨列,默认 1`rowspan`(跨行,默认 1被合并覆盖的单元格不再写对应 `<td>`
表头默认的白底白字视觉效果极差,必须设置背景和文字颜色,需在首行每个 `<td>` 上加 `<fill>`(配合 `bold` 与对比文字色)与正文行区分。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -38,13 +38,6 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
> **从任务清单定位并修改任务的最短路径**
> 1. 已知任务清单 GUID 时直接使用,不要先搜索;已知任务清单 applink 时,取 URL query 中的 `guid` 作为 `tasklist_guid`。
> 2. 只有清单名称或关键词、没有 GUID/applink 时,才调用一次 `+tasklist-search` 解析目标清单。
> 3. 按原生 API 规则先执行 `lark-cli schema task.tasklists.tasks`,再执行 `lark-cli task tasklists tasks --params '{"tasklist_guid":"<tasklist_guid>"}' --as user`。
> 4. 从清单任务结果中取任务的 `guid`,直接传给 `+update` 或 `+complete`;禁止传客户端展示编号(例如 `t104121`)。这两个 shortcut 也可直接接收包含 `guid=` 的任务 applink。
> 5. `+update` 返回 `updated_fields` 和每个任务的服务端 `confirmed` 字段;`+complete` 返回 `status`、`completed_at`、`already_completed`。这些字段已确认目标状态时,不要例行追加 `tasks get`;仅在服务端未返回所需字段或用户明确要求完整复核时再查询详情。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |

View File

@@ -9,23 +9,19 @@ Mark a task as completed.
```bash
# Complete a task
lark-cli task +complete --task-id "<task_guid>"
# A task applink is accepted directly; the CLI extracts its guid query value
lark-cli task +complete --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>"
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
| `--task-id <guid>` | Yes | The task GUID to complete. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
## Workflow
1. Confirm the task to complete.
2. Execute the command.
3. Read `data.status`, `data.completed_at`, and `data.already_completed` from the result. `already_completed: true` means the shortcut observed an already-completed task and skipped the PATCH.
4. Do not routinely call `task tasks get` when the result already reports `status: done` and a non-zero `completed_at`. Query details only if confirmation fields are absent or the user explicitly asks for a full verification.
3. Report success.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -13,9 +13,6 @@ lark-cli task +update --task-id "<task_guid>" --summary "New Summary"
# Update multiple tasks' due dates
lark-cli task +update --task-id "<task_guid>,<another_task_guid>" --due "+2d"
# A task applink is accepted directly; the CLI extracts its guid query value
lark-cli task +update --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>" --summary "New Summary"
# Update with JSON data
lark-cli task +update --task-id "<task_guid>" --data '{"description": "New description"}'
```
@@ -24,7 +21,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Comma-separated GUIDs/applinks are supported for multiple tasks. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
| `--task-id <guid>` | Yes | The task GUID to update. Comma-separated task GUIDs are supported for multiple tasks. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--summary <text>` | No | New summary/title for the task. |
| `--description <text>` | No | New description for the task. |
| `--due <time>` | No | New due date (supports relative time). |
@@ -34,8 +31,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
1. Confirm with the user the tasks to update and the fields.
2. Execute `lark-cli task +update --task-id "..." ...`
3. Read `data.updated_fields` and `data.tasks[].confirmed` from the result and report only the fields confirmed by the server.
4. Do not routinely call `task tasks get` after the update when `confirmed` already contains the required state. Query details only if a required field is absent or the user explicitly asks for a full verification.
3. Report the successful updates.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath(t *testing.T) {
setAppsDryRunEnv(t)
absolutePath := filepath.Join(t.TempDir(), "report.pdf")
require.NoError(t, os.WriteFile(absolutePath, []byte("dry-run-input"), 0o600))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+file-upload",
"--app-id", "app_x",
"--file", absolutePath,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "report.pdf", clie2e.DryRunGet(result.Stdout, "api.0.body.file_name").String())
}
func TestAppsFileUploadDryRun_RejectsMissingHostPath(t *testing.T) {
setAppsDryRunEnv(t)
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "report.pdf")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+file-upload",
"--app-id", "app_x",
"--file", missingAbsolutePath,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--file", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsFileUploadLiveWorkflow(t *testing.T) {
if strings.TrimSpace(os.Getenv("LARKSUITE_CLI_CONFIG_DIR")) == "" {
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated live-test config")
}
appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_FILE_APP_ID"))
if appID == "" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_FILE_APP_ID to a dedicated app for upload/delete testing")
}
fileName := fmt.Sprintf("lark-cli-host-path-e2e-%d.txt", time.Now().UnixNano())
absolutePath := filepath.Join(t.TempDir(), fileName)
content := []byte("host-path-live-e2e")
require.NoError(t, os.WriteFile(absolutePath, content, 0o600))
remotePath := ""
t.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
if remotePath == "" {
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+file-list", "--app-id", appID, "--name", fileName},
DefaultAs: "user",
})
if listErr != nil || listResult.ExitCode != 0 {
clie2e.ReportCleanupFailure(t, "find uploaded file "+fileName, listResult, listErr)
return
}
for _, item := range gjson.Get(listResult.Stdout, "data.items").Array() {
if item.Get("file_name").String() == fileName {
remotePath = item.Get("path").String()
break
}
}
}
if remotePath == "" {
return
}
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+file-delete", "--app-id", appID, "--path", remotePath},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "delete uploaded file "+remotePath, deleteResult, deleteErr)
if deleteErr == nil && deleteResult != nil && gjson.Get(deleteResult.Stdout, "data.results.0.status").String() != "ok" {
t.Errorf("cleanup delete did not report success: %s", deleteResult.Stdout)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+file-upload", "--app-id", appID, "--file", absolutePath},
DefaultAs: "user",
})
require.NoError(t, err)
uploadResult.AssertExitCode(t, 0)
uploadResult.AssertStdoutStatus(t, true)
remotePath = gjson.Get(uploadResult.Stdout, "data.path").String()
require.NotEmpty(t, remotePath, "stdout:\n%s", uploadResult.Stdout)
assert.Equal(t, fileName, gjson.Get(uploadResult.Stdout, "data.file_name").String(), "stdout:\n%s", uploadResult.Stdout)
getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+file-get", "--app-id", appID, "--path", remotePath},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || gjson.Get(result.Stdout, "data.path").String() != remotePath
},
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
assert.Equal(t, int64(len(content)), gjson.Get(getResult.Stdout, "data.size_bytes").Int(), "stdout:\n%s", getResult.Stdout)
}

View File

@@ -1,11 +1,11 @@
# Apps CLI E2E Coverage
## Metrics
- Denominator: 19 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (19/19)
- API dry-run coverage: 100% (17/17 API-backed commands)
- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (18/18)
- API dry-run coverage: 100% (16/16 API-backed commands)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup.
- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`.
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
@@ -14,8 +14,6 @@
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
- `TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath`: dry-run validates an absolute local file and derives its basename without uploading it; a missing source path is rejected before preview.
- `TestAppsFileUploadLiveWorkflow`: fixture-gated absolute-path upload, `+file-get` readback, and `+file-delete` cleanup in a dedicated app.
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
@@ -25,7 +23,7 @@
- `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers.
- `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members.
## Command Table
@@ -37,7 +35,6 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
| ✓ | apps +file-upload | shortcut | apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath; apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_RejectsMissingHostPath; apps_file_upload_live_test.go::TestAppsFileUploadLiveWorkflow | `--app-id`, `--file` (absolute or relative local path) | live workflow uses `LARK_CLI_E2E_APPS_FILE_APP_ID`, reads metadata back, and deletes the uploaded file |
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseFormSubmitDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-submit",
"--share-token", "shrXXXX",
"--json", `{"fields":{"Rating":5}}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/submit")
assert.Contains(t, output, `"share_token"`)
assert.Contains(t, output, "shrXXXX")
assert.Contains(t, output, `"method": "POST"`)
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlidesPresentationAliasesDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
aliases := []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
for _, alias := range aliases {
t.Run(alias, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+xml-get",
"--" + alias, "presAliasDryRun",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presAliasDryRun",
gjson.Get(result.Stdout, "data.api.0.url").String(),
result.Stdout,
)
})
}
}

View File

@@ -1,99 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestTaskIDHandlingDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_id_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_id_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
run := func(t *testing.T, args []string) *clie2e.Result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
return result
}
t.Run("GUID and applink produce equivalent update requests", func(t *testing.T) {
guidResult := run(t, []string{
"task", "+update", "--task-id", "task-guid-123", "--summary", "updated", "--dry-run",
})
guidResult.AssertExitCode(t, 0)
applinkResult := run(t, []string{
"task", "+update", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-123", "--summary", "updated", "--dry-run",
})
applinkResult.AssertExitCode(t, 0)
wantURL := "/open-apis/task/v2/tasks/task-guid-123"
require.Equal(t, wantURL, clie2e.DryRunGet(guidResult.Stdout, "api.0.url").String())
require.Equal(t, wantURL, clie2e.DryRunGet(applinkResult.Stdout, "api.0.url").String())
require.Equal(t, clie2e.DryRunGet(guidResult.Stdout, "api.0.body").Raw, clie2e.DryRunGet(applinkResult.Stdout, "api.0.body").Raw)
})
t.Run("multi-ID update previews every mutation", func(t *testing.T) {
result := run(t, []string{
"task", "+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/task?guid=task-guid-2",
"--summary", "updated",
"--dry-run",
})
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-1", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.1.method").String())
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-2", clie2e.DryRunGet(result.Stdout, "api.1.url").String())
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Raw, clie2e.DryRunGet(result.Stdout, "api.1.params").Raw)
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Raw, clie2e.DryRunGet(result.Stdout, "api.1.body").Raw)
})
t.Run("GUID and applink produce equivalent completion requests", func(t *testing.T) {
guidResult := run(t, []string{
"task", "+complete", "--task-id", "task-guid-456", "--dry-run",
})
guidResult.AssertExitCode(t, 0)
applinkResult := run(t, []string{
"task", "+complete", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-456", "--dry-run",
})
applinkResult.AssertExitCode(t, 0)
wantURL := "/open-apis/task/v2/tasks/task-guid-456"
for _, result := range []*clie2e.Result{guidResult, applinkResult} {
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.0.url").String())
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.1.url").String())
}
})
for _, shortcut := range []string{"+update", "+complete"} {
t.Run(shortcut+" rejects display numbers", func(t *testing.T) {
args := []string{"task", shortcut, "--task-id", "t12345", "--dry-run"}
if shortcut == "+update" {
args = append(args, "--summary", "must not be written")
}
result := run(t, args)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "guid=", "stderr:\n%s", result.Stderr)
require.False(t, gjson.Get(result.Stdout, "data.api").Exists(), "invalid input must not emit a dry-run API request\nstdout:\n%s", result.Stdout)
})
}
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/url"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestTaskIDHandlingWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalSummary := "lark-cli-e2e-task-id-original-" + suffix
updatedSummary := "lark-cli-e2e-task-id-updated-" + suffix
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": originalSummary,
"description": "created by task ID handling workflow",
},
})
taskApplink := "https://applink.larksuite.com/client/todo/task?guid=" + url.QueryEscape(taskGUID)
t.Run("update accepts task applink", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", taskApplink, "--summary", updatedSummary},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.tasks.0.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.tasks.0.confirmed.summary").String(), "stdout:\n%s", result.Stdout)
})
t.Run("display number is rejected without modifying task", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", "t12345", "--summary", "must-not-be-written-" + suffix},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
assert.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
getResult, getErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, getErr)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
assert.Equal(t, updatedSummary, gjson.Get(getResult.Stdout, "data.task.summary").String(), "stdout:\n%s", getResult.Stdout)
})
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestTask_SearchPaginationDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_search_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_search_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
command string
url string
}{
{
name: "tasks",
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"task", tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, tt.url, clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "initial_pt", clie2e.DryRunGet(out, "api.0.params.page_token").String(), out)
require.Equal(t, "pagination", clie2e.DryRunGet(out, "api.0.body.query").String(), out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.page_token").Exists(), out)
})
}
}